From 979eb496e537ff2f84096dd7a8c1cf77b3b56e3a Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:31 +0200 Subject: [PATCH 01/28] Pin server/public to the Spaces RBAC core branch --- go.mod | 46 ++++++++++++++++- go.sum | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 4dca5b5..93cc973 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 + github.com/mattermost/mattermost/server/public v0.4.4-0.20260727152953-09b1991e5d5d github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 github.com/mattermost/morph v1.1.0 github.com/mattermost/squirrel v0.5.0 @@ -23,18 +23,38 @@ require ( github.com/wiggin77/merror v1.0.5 ) +require github.com/mattermost/testcontainers-mattermost-go v0.1.0 + require ( + dario.cat/mergo v1.0.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beevik/etree v1.6.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/containerd v1.7.18 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.1.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -44,8 +64,11 @@ require ( github.com/hashicorp/go-plugin v1.8.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/gosaml2 v0.10.0 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect @@ -53,21 +76,42 @@ require ( github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oklog/run v1.2.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/testcontainers/testcontainers-go v0.35.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/srslog v1.0.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 3a0cdde..13d0131 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= @@ -10,9 +12,15 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= @@ -22,15 +30,35 @@ github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBT github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= +github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -39,6 +67,8 @@ github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go. github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= @@ -48,16 +78,21 @@ github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aev github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -68,6 +103,9 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= @@ -87,7 +125,10 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0 h1:WcmKMm43DR7RdtlkEXQJyo5ws8iTp98CyhCCbOHMvNI= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -102,6 +143,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= @@ -112,7 +161,10 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -130,7 +182,11 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= @@ -140,14 +196,16 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 h1:L/o7nmoq4fv/wfcaen7tr85GxZ/V05hpzfpI3/TuOto= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260727152953-09b1991e5d5d h1:Ps8dcPd1SHI8SK261SE7VEytbRHcF0ndirbDQeyMfjw= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260727152953-09b1991e5d5d/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= github.com/mattermost/squirrel v0.5.0 h1:81QPS0aA+inQbpA7Pzmv6O9sWwB6VaBh/VYw3oJf8ZY= github.com/mattermost/squirrel v0.5.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU= +github.com/mattermost/testcontainers-mattermost-go v0.1.0 h1:oWNqnU2mua2PYMSornWjjiFQUZqPNeODSQSsWjFcuMw= +github.com/mattermost/testcontainers-mattermost-go v0.1.0/go.mod h1:XXKu9w+dsAavOjujftqSV5L0+GkybOSpWlmkNicUpEQ= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -162,15 +220,33 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mdelapenya/tlscert v0.1.0 h1:YTpF579PYUX475eOL+6zyEO3ngLTOUWck78NBuJVXaM= +github.com/mdelapenya/tlscert v0.1.0/go.mod h1:wrbyM/DwbFCeCeqdPX/8c6hNOqQgbf0rUDErE1uD+64= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -186,6 +262,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= @@ -194,13 +272,19 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= @@ -228,16 +312,29 @@ github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC4 github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I= +github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= @@ -248,13 +345,23 @@ github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0= github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -263,17 +370,23 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -284,6 +397,10 @@ golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= @@ -296,6 +413,9 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -303,30 +423,50 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= @@ -341,6 +481,9 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= @@ -367,6 +510,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 10e5598063c4196d3e5cbc2fdba56ca276a7c52a Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:43 +0200 Subject: [PATCH 02/28] Add space capability model and ViewAccess field --- server/model/space.go | 73 ++++++- server/model/space_capabilities.go | 274 ++++++++++++++++++++++++ server/model/space_capabilities_test.go | 182 ++++++++++++++++ server/model/space_test.go | 9 +- 4 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 server/model/space_capabilities.go create mode 100644 server/model/space_capabilities_test.go diff --git a/server/model/space.go b/server/model/space.go index a5ccb72..3632221 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -19,6 +19,13 @@ const ( // SpacePropsMaxBytes caps the serialized size of the opaque Props map. SpacePropsMaxBytes = 64 * 1024 + + // ViewAccessOpen makes a space readable by any active member of its team, not just backing- + // channel members (see the app-layer read resolver). ViewAccessPrivate restricts reads to + // backing-channel members. There is no third value; PreSave deliberately does not default + // ViewAccess — the caller (app layer) always decides it explicitly. + ViewAccessOpen = "open" + ViewAccessPrivate = "private" ) // Space is stored in the DOCS_Space table. Each space owns a backing MM channel (ChannelId). @@ -33,10 +40,35 @@ type Space struct { Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` Props mmmodel.StringInterface `json:"props"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - SortOrder int64 `json:"sort_order"` + // ViewAccess is one of ViewAccessOpen/ViewAccessPrivate. It gates non-member reads only — + // a member's read is always settled by membership. + ViewAccess string `json:"view_access"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + SortOrder int64 `json:"sort_order"` +} + +// SpaceWithAccess is the response wrapper for GET /spaces/{id} and SetSpaceDefaultCapabilities: it +// carries the caller-relevant access state alongside the plain Space fields. The anonymous Space +// embed keeps the JSON flat, mirroring core's ChannelMemberWithTeamData pattern. DefaultCapabilities +// is the space's default capability set and Capabilities is the caller's own effective set; both are +// read_page-free (the implicit baseline) and non-nil-on-empty. +type SpaceWithAccess struct { + Space + DefaultCapabilities []string `json:"default_capabilities"` + Capabilities []string `json:"capabilities"` +} + +// EnsureCapabilities normalizes DefaultCapabilities and Capabilities to non-nil slices so they +// marshal as JSON [] rather than null, mirroring the Space.GetProps discipline. +func (w *SpaceWithAccess) EnsureCapabilities() { + if w.DefaultCapabilities == nil { + w.DefaultCapabilities = []string{} + } + if w.Capabilities == nil { + w.Capabilities = []string{} + } } // SpacePatch carries a partial update to a space's mutable fields. A nil field is left unchanged; a @@ -46,20 +78,39 @@ type SpacePatch struct { Description *string `json:"description"` Icon *string `json:"icon"` Props *mmmodel.StringInterface `json:"props"` + ViewAccess *string `json:"view_access"` } // SpaceMember is the API-facing view of a user's membership in a space. Membership is backed by -// the space's channel, but the channel-membership mechanics (channel id, roles, notify props) -// stay internal — only the user is exposed, mirroring how Space hides its ChannelId. +// the space's channel; this type projects the caller's effective membership state — capabilities, +// admin/guest standing — while the raw channel mechanics (channel id, generated scheme-role names, +// ExplicitRoles string, notify props) stay internal. type SpaceMember struct { UserId string `json:"user_id"` + // Capabilities is the member's effective capability set (space default union granted). + Capabilities []string `json:"capabilities"` + // GrantedCapabilities is the member's per-member granted set, beyond the space default. + GrantedCapabilities []string `json:"granted_capabilities"` + IsAdmin bool `json:"is_admin"` + IsGuest bool `json:"is_guest"` +} + +// EnsureCapabilities normalizes Capabilities and GrantedCapabilities to non-nil slices so they +// marshal as JSON [] rather than null, mirroring the Space.GetProps discipline. +func (m *SpaceMember) EnsureCapabilities() { + if m.Capabilities == nil { + m.Capabilities = []string{} + } + if m.GrantedCapabilities == nil { + m.GrantedCapabilities = []string{} + } } // IsValid rejects a nil patch and an all-nil-fields patch — both no-ops that would otherwise bump // UpdateAt and consume the optimistic-lock baseline without a real change. Enforced here, not just // in the service, so callers that bypass the service still uphold it — mirroring PagePatch.IsValid. func (p *SpacePatch) IsValid() *mmmodel.AppError { - if p == nil || (p.Title == nil && p.Description == nil && p.Icon == nil && p.Props == nil) { + if p == nil || (p.Title == nil && p.Description == nil && p.Icon == nil && p.Props == nil && p.ViewAccess == nil) { return mmmodel.NewAppError("SpacePatch.IsValid", "model.space.patch.nothing_to_update.app_error", nil, "", http.StatusBadRequest) } return nil @@ -84,6 +135,9 @@ func (s *Space) Patch(patch *SpacePatch) { if patch.Props != nil { s.Props = maps.Clone(*patch.Props) } + if patch.ViewAccess != nil { + s.ViewAccess = *patch.ViewAccess + } } // PreSave sanitizes Space and defaults its Id-independent fields before insert. @@ -134,6 +188,7 @@ func (s *Space) Auditable() map[string]any { "description": s.Description, "icon": s.Icon, "props": s.GetProps(), + "view_access": s.ViewAccess, "create_at": s.CreateAt, "update_at": s.UpdateAt, "delete_at": s.DeleteAt, @@ -187,6 +242,10 @@ func (s *Space) IsValid() *mmmodel.AppError { return err } + if s.ViewAccess != ViewAccessOpen && s.ViewAccess != ViewAccessPrivate { + return mmmodel.NewAppError("Space.IsValid", "model.space.is_valid.view_access.app_error", nil, "id="+s.Id, http.StatusBadRequest) + } + return nil } diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go new file mode 100644 index 0000000..2989dbe --- /dev/null +++ b/server/model/space_capabilities.go @@ -0,0 +1,274 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "maps" + "net/http" + "slices" + "strings" + + mmmodel "github.com/mattermost/mattermost/server/public/model" +) + +// The capability vocabulary is the core page-permission id strings themselves, so the API speaks +// the same tokens core enforces — no invented level names. Consumed only as symbols (never a +// re-declared permission list), keeping the plugin single-sourced against core (see the drift +// defence discussion on the round-trip tests below). +var ( + // CapabilityReadPage is the baseline capability, always present in an effective set, and never + // independently grantable (see ValidateGrantedCapabilities/ValidateDefaultCapabilities). + CapabilityReadPage = mmmodel.PermissionReadPage.Id + CapabilityCreatePage = mmmodel.PermissionCreatePage.Id + CapabilityCommentPage = mmmodel.PermissionCommentPage.Id + CapabilityEditPage = mmmodel.PermissionEditPage.Id + CapabilityDeleteOwnPage = mmmodel.PermissionDeleteOwnPage.Id + // CapabilityDeletePage (delete-any) is never independently grantable — it rides only the + // admin capability (SchemeAdmin), never an ExplicitRoles atomic role. + CapabilityDeletePage = mmmodel.PermissionDeletePage.Id + // CapabilityAdminSpace is the admin capability: a member-grant target (toggles SchemeAdmin), + // but never a valid space-default capability. + CapabilityAdminSpace = mmmodel.PermissionAdminSpace.Id +) + +// grantableMemberCapabilities is the wire vocabulary a caller may explicitly grant to a member: +// the four atomic per-page capabilities plus the admin capability. +var grantableMemberCapabilities = map[string]bool{ + CapabilityCreatePage: true, + CapabilityCommentPage: true, + CapabilityEditPage: true, + CapabilityDeleteOwnPage: true, + CapabilityAdminSpace: true, +} + +// grantableDefaultCapabilities is the wire vocabulary a space-default capability set may hold: +// the four atomic per-page capabilities. admin_space is member-grant-only, never a space default. +var grantableDefaultCapabilities = map[string]bool{ + CapabilityCreatePage: true, + CapabilityCommentPage: true, + CapabilityEditPage: true, + CapabilityDeleteOwnPage: true, +} + +// capabilityAtomicRole maps each non-admin grantable capability to the core atomic capability +// role that carries it in ExplicitRoles. +var capabilityAtomicRole = map[string]string{ + CapabilityCreatePage: mmmodel.SpacePageCreatorRoleId, + CapabilityCommentPage: mmmodel.SpacePageCommenterRoleId, + CapabilityEditPage: mmmodel.SpacePageEditorRoleId, + CapabilityDeleteOwnPage: mmmodel.SpacePageDeleterOwnRoleId, +} + +// atomicRoleCapability is the reverse of capabilityAtomicRole, used to parse a stored +// ExplicitRoles string back into the granted capability set. +var atomicRoleCapability = map[string]string{ + mmmodel.SpacePageCreatorRoleId: CapabilityCreatePage, + mmmodel.SpacePageCommenterRoleId: CapabilityCommentPage, + mmmodel.SpacePageEditorRoleId: CapabilityEditPage, + mmmodel.SpacePageDeleterOwnRoleId: CapabilityDeleteOwnPage, +} + +// stripReadPage projects a core permission slice onto its wire id strings with the implicit +// read_page baseline removed, so a canonical core permission set can be single-sourced into the +// read_page-free wire vocabulary without drift. +func stripReadPage(permissions []*mmmodel.Permission) []string { + return slices.DeleteFunc(mmmodel.PermissionIDs(permissions), func(id string) bool { + return id == CapabilityReadPage + }) +} + +// spaceAdminEffectiveCapabilities is the full capability set a SchemeAdmin member effectively +// holds, single-sourced from core's canonical admin permission slice (SpaceAdminRolePermissions, +// which already includes read_page). +var spaceAdminEffectiveCapabilities = mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions) + +// presetCapabilitySets are the three seeded default-capability presets in wire form (read_page- +// free — the baseline is implicit and never listed), single-sourced from core's canonical +// permission slices. +var presetCapabilitySets = map[string][]string{ + mmmodel.SchemeNameSpaceContribute: stripReadPage(mmmodel.SpaceDefaultContributePermissions), + mmmodel.SchemeNameSpaceComment: stripReadPage(mmmodel.SpaceDefaultCommentPermissions), + mmmodel.SchemeNameSpaceReadOnly: stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions), +} + +// ValidateGrantedCapabilities validates a per-member granted-capability request: each token must +// be one of the grantable member capabilities. read_page is rejected as the non-grantable +// baseline and delete_page as admin-only; an unknown token is rejected. Dedup-tolerant. +func ValidateGrantedCapabilities(caps []string) *mmmodel.AppError { + for _, c := range caps { + if c == CapabilityReadPage { + return mmmodel.NewAppError("ValidateGrantedCapabilities", "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + } + if c == CapabilityDeletePage { + return mmmodel.NewAppError("ValidateGrantedCapabilities", "model.space_capabilities.delete_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + } + if !grantableMemberCapabilities[c] { + return mmmodel.NewAppError("ValidateGrantedCapabilities", "model.space_capabilities.unknown_capability.app_error", map[string]any{"Capability": c}, "", http.StatusBadRequest) + } + } + return nil +} + +// ValidateDefaultCapabilities validates a space-default capability set: same rule as +// ValidateGrantedCapabilities, plus admin_space is also rejected — a space default is never +// admin-granting. +func ValidateDefaultCapabilities(caps []string) *mmmodel.AppError { + for _, c := range caps { + if c == CapabilityReadPage { + return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + } + if c == CapabilityDeletePage { + return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.delete_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + } + if c == CapabilityAdminSpace { + return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.admin_not_a_default.app_error", nil, "", http.StatusBadRequest) + } + if !grantableDefaultCapabilities[c] { + return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.unknown_capability.app_error", map[string]any{"Capability": c}, "", http.StatusBadRequest) + } + } + return nil +} + +// RolesForCapabilities maps the requested non-admin capabilities to their atomic role names for +// ExplicitRoles, and reports whether admin_space was requested (schemeAdmin). The base +// schemeUserRole token is always emitted first — core rejects a member update that leaves the base +// scheme role unset. Pure: schemeUserRole (the scheme's generated user-role name) comes from the +// caller, resolved via a store lookup elsewhere. +func RolesForCapabilities(caps []string, schemeUserRole string) (explicitRoles string, schemeAdmin bool) { + roles := []string{schemeUserRole} + for _, c := range NormalizeCapabilitySet(caps) { + if c == CapabilityAdminSpace { + schemeAdmin = true + continue + } + if roleName, ok := capabilityAtomicRole[c]; ok { + roles = append(roles, roleName) + } + } + return strings.Join(roles, " "), schemeAdmin +} + +// MemberCapabilities is the reverse projection of a member's stored role state onto the capability +// vocabulary: Effective is the member's effective capability set, Granted is the per-member +// granted set beyond the space default. +type MemberCapabilities struct { + Effective []string + Granted []string + IsAdmin bool + IsGuest bool +} + +// CapabilitiesFromMember reverse-projects a member's raw role state onto the capability vocabulary. +// explicitRoles is the raw space-delimited ChannelMember.ExplicitRoles string; any token that is +// not an atomic capability role is ignored (harmless if the base scheme token is passed too). +// defaultCaps is the space's default capability set (wire form, read_page-free). +// +// A SchemeGuest member's effective set is read_page union Granted only — never the space default, +// since a guest resolves through the read-only DefaultChannelGuestRole, not the scheme's user-role +// default. A SchemeAdmin member's effective set additionally includes the full canonical admin +// capability set, since SchemeAdmin resolves through DefaultChannelAdminRole regardless of what is +// (or isn't) recorded in ExplicitRoles/the space default. Pure: the model never touches the store. +func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, defaultCaps []string) MemberCapabilities { + granted := make(map[string]bool) + for token := range strings.FieldsSeq(explicitRoles) { + if c, ok := atomicRoleCapability[token]; ok { + granted[c] = true + } + } + if schemeAdmin { + granted[CapabilityAdminSpace] = true + } + grantedList := NormalizeCapabilitySet(slices.Collect(maps.Keys(granted))) + + effective := map[string]bool{CapabilityReadPage: true} + switch { + case schemeGuest: + for _, c := range grantedList { + effective[c] = true + } + case schemeAdmin: + for _, c := range spaceAdminEffectiveCapabilities { + effective[c] = true + } + for _, c := range defaultCaps { + effective[c] = true + } + for _, c := range grantedList { + effective[c] = true + } + default: + for _, c := range defaultCaps { + effective[c] = true + } + for _, c := range grantedList { + effective[c] = true + } + } + + return MemberCapabilities{ + Effective: NormalizeCapabilitySet(slices.Collect(maps.Keys(effective))), + Granted: grantedList, + IsAdmin: schemeAdmin, + IsGuest: schemeGuest, + } +} + +// AdminEffectiveCapabilities returns the full capability set a SchemeAdmin effectively holds, wire +// form, non-nil. +func AdminEffectiveCapabilities() []string { + return NormalizeCapabilitySet(spaceAdminEffectiveCapabilities) +} + +// DefaultCapabilitiesFromPermissions projects a custom scheme's stored user-role permission set +// (raw core permission ids) onto the wire default-capability vocabulary: read_page (the implicit +// baseline) and any non-default-capability permission are stripped; only the grantable default +// capability tokens survive. +func DefaultCapabilitiesFromPermissions(permissions []string) []string { + out := make([]string, 0, len(permissions)) + for _, p := range permissions { + if grantableDefaultCapabilities[p] { + out = append(out, p) + } + } + return NormalizeCapabilitySet(out) +} + +// DefaultCapabilitiesForSchemeName returns the wire-form default capability set for one of the +// three seeded preset scheme names, or false if name is not a preset. +func DefaultCapabilitiesForSchemeName(name string) ([]string, bool) { + caps, ok := presetCapabilitySets[name] + if !ok { + return nil, false + } + return NormalizeCapabilitySet(caps), true +} + +// SchemeNameForDefaultCapabilities returns the seeded preset scheme name matching caps, or false +// if caps does not match any preset. Recognition is set equality — order-insensitive, deduplicated +// — never a raw array comparison. +func SchemeNameForDefaultCapabilities(caps []string) (string, bool) { + normalized := NormalizeCapabilitySet(caps) + for name, preset := range presetCapabilitySets { + if slices.Equal(NormalizeCapabilitySet(preset), normalized) { + return name, true + } + } + return "", false +} + +// NormalizeCapabilitySet dedupes and sorts caps into a deterministic, non-nil capability slice. +func NormalizeCapabilitySet(caps []string) []string { + seen := make(map[string]bool, len(caps)) + out := make([]string, 0, len(caps)) + for _, c := range caps { + if c == "" || seen[c] { + continue + } + seen[c] = true + out = append(out, c) + } + slices.Sort(out) + return out +} diff --git a/server/model/space_capabilities_test.go b/server/model/space_capabilities_test.go new file mode 100644 index 0000000..4ed327f --- /dev/null +++ b/server/model/space_capabilities_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model_test + +import ( + "slices" + "strings" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// grantableCapabilities is the full grant vocabulary (the four atomic per-page capabilities plus +// admin_space), used to enumerate every subset for the round-trip test below. +var grantableCapabilities = []string{ + model.CapabilityCreatePage, + model.CapabilityCommentPage, + model.CapabilityEditPage, + model.CapabilityDeleteOwnPage, + model.CapabilityAdminSpace, +} + +// TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip verifies that every subset of the +// grantable capabilities survives RolesForCapabilities -> (core persistence, which drops the base +// scheme-user token and keeps only the atomic role names) -> CapabilitiesFromMember unchanged. +func TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip(t *testing.T) { + const schemeUserRole = "generated_scheme_user_role" + + for mask := range 1 << len(grantableCapabilities) { + var caps []string + for i, c := range grantableCapabilities { + if mask&(1< 0 && slices.Contains(caps, model.CapabilityAdminSpace), mc.IsAdmin) + } +} + +// TestCapabilitiesFromMember_Guest verifies that a SchemeGuest member's effective capabilities are +// read_page union their granted set only — the space default never contributes, since a guest +// resolves through the read-only guest role rather than the scheme's user-role default. +func TestCapabilitiesFromMember_Guest(t *testing.T) { + t.Run("grant-free guest is read-only", func(t *testing.T) { + mc := model.CapabilitiesFromMember("", false, true, []string{model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage}) + require.Equal(t, []string{model.CapabilityReadPage}, mc.Effective) + require.Empty(t, mc.Granted) + require.True(t, mc.IsGuest) + require.False(t, mc.IsAdmin) + }) + + t.Run("surviving grants project without the default", func(t *testing.T) { + explicitRoles := mmmodel.SpacePageCreatorRoleId + " " + mmmodel.SpacePageEditorRoleId + // A contribute default that must NOT leak into a guest's effective set. + defaultCaps := []string{model.CapabilityCommentPage, model.CapabilityCreatePage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage} + + mc := model.CapabilitiesFromMember(explicitRoles, false, true, defaultCaps) + + require.Equal(t, model.NormalizeCapabilitySet([]string{model.CapabilityCreatePage, model.CapabilityEditPage}), mc.Granted) + require.Equal(t, model.NormalizeCapabilitySet([]string{model.CapabilityReadPage, model.CapabilityCreatePage, model.CapabilityEditPage}), mc.Effective) + require.NotContains(t, mc.Effective, model.CapabilityCommentPage) + require.NotContains(t, mc.Effective, model.CapabilityDeleteOwnPage) + require.True(t, mc.IsGuest) + }) +} + +// TestPresetRoundTrip verifies the default-preset <-> scheme-name maps invert each other for the +// three seeded presets, and that preset recognition is order-insensitive and dedup-tolerant. +func TestPresetRoundTrip(t *testing.T) { + presetNames := []string{ + mmmodel.SchemeNameSpaceContribute, + mmmodel.SchemeNameSpaceComment, + mmmodel.SchemeNameSpaceReadOnly, + } + + for _, name := range presetNames { + t.Run(name, func(t *testing.T) { + caps, ok := model.DefaultCapabilitiesForSchemeName(name) + require.True(t, ok) + + recognizedName, ok := model.SchemeNameForDefaultCapabilities(caps) + require.True(t, ok) + require.Equal(t, name, recognizedName) + + roundTripped, ok := model.DefaultCapabilitiesForSchemeName(recognizedName) + require.True(t, ok) + require.Equal(t, caps, roundTripped) + }) + } + + t.Run("recognition is order-insensitive and dedup-tolerant", func(t *testing.T) { + contribute, ok := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + require.True(t, ok) + require.NotEmpty(t, contribute) + + // Reverse the set and duplicate every entry: still recognized as contribute. + shuffled := make([]string, 0, len(contribute)*2) + for i := len(contribute) - 1; i >= 0; i-- { + shuffled = append(shuffled, contribute[i], contribute[i]) + } + + name, ok := model.SchemeNameForDefaultCapabilities(shuffled) + require.True(t, ok) + require.Equal(t, mmmodel.SchemeNameSpaceContribute, name) + }) + + t.Run("a non-preset set is not recognized", func(t *testing.T) { + _, ok := model.SchemeNameForDefaultCapabilities([]string{model.CapabilityCreatePage}) + require.False(t, ok) + }) +} + +// TestValidateGrantedCapabilities verifies the per-member grant validator: read_page is rejected +// as the non-grantable baseline, admin_space is accepted, and an unknown token is rejected. +func TestValidateGrantedCapabilities(t *testing.T) { + t.Run("read_page rejected", func(t *testing.T) { + aerr := model.ValidateGrantedCapabilities([]string{model.CapabilityReadPage}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.read_page_not_grantable.app_error", aerr.Id) + }) + + t.Run("admin_space accepted", func(t *testing.T) { + require.Nil(t, model.ValidateGrantedCapabilities([]string{model.CapabilityAdminSpace})) + }) + + t.Run("unknown token rejected", func(t *testing.T) { + aerr := model.ValidateGrantedCapabilities([]string{"not_a_real_capability"}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.unknown_capability.app_error", aerr.Id) + }) + + t.Run("every non-admin grantable capability accepted", func(t *testing.T) { + require.Nil(t, model.ValidateGrantedCapabilities([]string{ + model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage, + })) + }) +} + +// TestValidateDefaultCapabilities verifies the space-default validator: read_page and admin_space +// are both rejected (the baseline is implicit; admin is never a default), and an unknown token is +// rejected. +func TestValidateDefaultCapabilities(t *testing.T) { + t.Run("read_page rejected", func(t *testing.T) { + aerr := model.ValidateDefaultCapabilities([]string{model.CapabilityReadPage}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.read_page_not_grantable.app_error", aerr.Id) + }) + + t.Run("admin_space rejected", func(t *testing.T) { + aerr := model.ValidateDefaultCapabilities([]string{model.CapabilityAdminSpace}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.admin_not_a_default.app_error", aerr.Id) + }) + + t.Run("unknown token rejected", func(t *testing.T) { + aerr := model.ValidateDefaultCapabilities([]string{"not_a_real_capability"}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.unknown_capability.app_error", aerr.Id) + }) + + t.Run("every non-admin grantable capability accepted", func(t *testing.T) { + require.Nil(t, model.ValidateDefaultCapabilities([]string{ + model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage, + })) + }) +} diff --git a/server/model/space_test.go b/server/model/space_test.go index 01002e7..55ebfac 100644 --- a/server/model/space_test.go +++ b/server/model/space_test.go @@ -15,10 +15,11 @@ import ( func validSpace() *model.Space { s := &model.Space{ - ChannelId: mmmodel.NewId(), - TeamId: mmmodel.NewId(), - CreatorId: mmmodel.NewId(), - Title: "Title", + ChannelId: mmmodel.NewId(), + TeamId: mmmodel.NewId(), + CreatorId: mmmodel.NewId(), + Title: "Title", + ViewAccess: model.ViewAccessOpen, } s.PreSave() return s From 67ec83e7bc75a0bbd78f5305d6c0d965f9ed4daf Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:43 +0200 Subject: [PATCH 03/28] Add space scheme store and ViewAccess migration --- .../000007_add_viewaccess_to_spaces.down.sql | 2 + .../000007_add_viewaccess_to_spaces.up.sql | 5 + server/store/scheme_store.go | 262 ++++++++++++++++++ server/store/scheme_store_test.go | 149 ++++++++++ server/store/space_store.go | 55 ++-- server/store/store.go | 33 +++ server/store/store_test.go | 45 ++- 7 files changed, 524 insertions(+), 27 deletions(-) create mode 100644 server/store/migrations/000007_add_viewaccess_to_spaces.down.sql create mode 100644 server/store/migrations/000007_add_viewaccess_to_spaces.up.sql create mode 100644 server/store/scheme_store.go create mode 100644 server/store/scheme_store_test.go diff --git a/server/store/migrations/000007_add_viewaccess_to_spaces.down.sql b/server/store/migrations/000007_add_viewaccess_to_spaces.down.sql new file mode 100644 index 0000000..00fcd64 --- /dev/null +++ b/server/store/migrations/000007_add_viewaccess_to_spaces.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE DOCS_Space DROP CONSTRAINT IF EXISTS chk_docs_space_view_access; +ALTER TABLE DOCS_Space DROP COLUMN IF EXISTS ViewAccess; diff --git a/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql b/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql new file mode 100644 index 0000000..359043c --- /dev/null +++ b/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE DOCS_Space ADD COLUMN IF NOT EXISTS ViewAccess VARCHAR(16) NOT NULL DEFAULT 'private'; + +-- The DDL default is 'private' (fail-closed): every plugin insert writes ViewAccess explicitly, +-- so this default only protects a row the application somehow left unset. +ALTER TABLE DOCS_Space ADD CONSTRAINT chk_docs_space_view_access CHECK (ViewAccess IN ('open', 'private')); diff --git a/server/store/scheme_store.go b/server/store/scheme_store.go new file mode 100644 index 0000000..d3a9644 --- /dev/null +++ b/server/store/scheme_store.go @@ -0,0 +1,262 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package store + +import ( + "database/sql" + "strings" + + "github.com/jmoiron/sqlx" + mmmodel "github.com/mattermost/mattermost/server/public/model" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +// spaceCustomSchemeDisplayName is the DisplayName of every space-private custom scheme this store +// creates: one immutable scheme per non-preset default-capability set. +const spaceCustomSchemeDisplayName = "Space Custom Scheme" + +// spaceCustomSchemeNamePrefix namespaces the Name of every space-private custom scheme this store +// creates. Core reserves only the three seeded preset names (mmmodel.IsSpaceSchemeName), so this +// namespace belongs to the plugin: it is what DeleteSpaceCustomSchemeIfUnreferenced matches on to +// prove a scheme is one this store created before deleting it. +const spaceCustomSchemeNamePrefix = "docs_space_custom_" + +// SchemeRoles is the generated channel-scheme role names governing one backing channel's scheme. +// Space capability grants must reference these generated names, not the literal +// channel_user/channel_admin roles: on a scheme-backed channel, core rejects the literal. +type SchemeRoles struct { + SchemeId string `db:"scheme_id"` + SchemeName string `db:"scheme_name"` + UserRoleName string `db:"user_role_name"` + AdminRoleName string `db:"admin_role_name"` + GuestRoleName string `db:"guest_role_name"` +} + +// GetSchemeRolesForChannel returns the generated scheme role names governing channelID's channel +// scheme. Returns ErrNotFound when the channel does not exist or carries no scheme. There is +// deliberately no DeleteAt filter on the channel: a soft-deleted space is restorable and keeps its +// SchemeId. +func (s *Store) GetSchemeRolesForChannel(channelID string) (*SchemeRoles, error) { + if channelID == "" { + return nil, &ErrInvalidInput{Entity: "Channel", Field: "id", Value: channelID} + } + + query := s.getQueryBuilder(). + Select( + "s.Id AS scheme_id", + "s.Name AS scheme_name", + "s.DefaultChannelUserRole AS user_role_name", + "s.DefaultChannelAdminRole AS admin_role_name", + "s.DefaultChannelGuestRole AS guest_role_name", + ). + From("Channels c"). + Join("Schemes s ON s.Id = c.SchemeId"). + Where(sq.Eq{"c.Id": channelID}) + + var roles SchemeRoles + if err := s.getBuilder(s.db, &roles, query); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, &ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + return nil, errors.Wrap(err, "unable_to_get_scheme_roles_for_channel") + } + return &roles, nil +} + +// GetSchemeIdByName returns the id of the scheme with the given name, or ErrNotFound if none +// exists. +func (s *Store) GetSchemeIdByName(name string) (string, error) { + if name == "" { + return "", &ErrInvalidInput{Entity: "Scheme", Field: "name", Value: name} + } + + query := s.getQueryBuilder(). + Select("Id"). + From("Schemes"). + Where(sq.Eq{"Name": name}) + + var id string + if err := s.getBuilder(s.db, &id, query); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", &ErrNotFound{EntityName: "Scheme", ID: name} + } + return "", errors.Wrap(err, "unable_to_get_scheme_by_name") + } + return id, nil +} + +// GetRolePermissionsByName returns the permission ids granted by the named role. +// Roles.Permissions is stored as a space-joined string column, not an array. +func (s *Store) GetRolePermissionsByName(roleName string) ([]string, error) { + if roleName == "" { + return nil, &ErrInvalidInput{Entity: "Role", Field: "name", Value: roleName} + } + + query := s.getQueryBuilder(). + Select("Permissions"). + From("Roles"). + Where(sq.Eq{"Name": roleName}) + + var permissions string + if err := s.getBuilder(s.db, &permissions, query); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, &ErrNotFound{EntityName: "Role", ID: roleName} + } + return nil, errors.Wrap(err, "unable_to_get_role_permissions") + } + return strings.Fields(permissions), nil +} + +// CreateSpaceCustomScheme creates one immutable space-private channel scheme with three generated +// roles carrying exactly the given permission sets (user/admin/guest), and returns the new +// scheme's id. Used when a space's default capability set (or the canonical admin/guest +// permission sets) doesn't match one of the three seeded presets. The scheme is unreferenced by +// any channel until the caller repoints the space's backing channel at it. +func (s *Store) CreateSpaceCustomScheme(userPermissions, adminPermissions, guestPermissions []string) (_ string, err error) { + tx, cancel, err := s.beginTxBounded() + if err != nil { + return "", errors.Wrap(err, "begin_transaction") + } + defer cancel() + defer s.finalizeTransaction(tx, &err) + + schemeID := mmmodel.NewId() + schemeName := spaceCustomSchemeNamePrefix + mmmodel.NewId() + now := mmmodel.GetMillis() + + userRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, "Channel User Role for Scheme", userPermissions, now) + if roleErr != nil { + return "", roleErr + } + adminRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, "Channel Admin Role for Scheme", adminPermissions, now) + if roleErr != nil { + return "", roleErr + } + guestRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, "Channel Guest Role for Scheme", guestPermissions, now) + if roleErr != nil { + return "", roleErr + } + + schemeBuilder := s.getQueryBuilder(). + Insert("Schemes"). + Columns( + "Id", "Name", "DisplayName", "Description", "Scope", + "DefaultTeamAdminRole", "DefaultTeamUserRole", "DefaultTeamGuestRole", + "DefaultChannelAdminRole", "DefaultChannelUserRole", "DefaultChannelGuestRole", + "CreateAt", "UpdateAt", "DeleteAt", + "DefaultPlaybookAdminRole", "DefaultPlaybookMemberRole", "DefaultRunAdminRole", "DefaultRunMemberRole", + ). + Values( + schemeID, schemeName, spaceCustomSchemeDisplayName, "", mmmodel.SchemeScopeChannel, + "", "", "", + adminRoleName, userRoleName, guestRoleName, + now, now, 0, + "", "", "", "", + ) + if _, execErr := s.execBuilder(tx, schemeBuilder); execErr != nil { + return "", errors.Wrap(execErr, "unable_to_save_space_custom_scheme") + } + + if err = tx.Commit(); err != nil { + return "", errors.Wrap(err, "commit_transaction") + } + return schemeID, nil +} + +// createSchemeRole inserts one generated, exact-permission, SchemeManaged role belonging to a +// space-private custom scheme, mirroring core's createScheme role shape: SchemeManaged true (the +// discriminator UpdateChannelMemberRoles requires to accept the generated name on the wire), +// BuiltIn false, SchemeId set. Must run inside tx. +func (s *Store) createSchemeRole(tx *sqlx.Tx, schemeID, schemeName, displayNamePrefix string, permissions []string, now int64) (string, error) { + roleName := mmmodel.NewId() + builder := s.getQueryBuilder(). + Insert("Roles"). + Columns("Id", "Name", "DisplayName", "Description", "Permissions", "CreateAt", "UpdateAt", "DeleteAt", "SchemeManaged", "BuiltIn", "SchemeId"). + Values(mmmodel.NewId(), roleName, displayNamePrefix+" "+schemeName, "", joinRolePermissions(permissions), now, now, 0, true, false, schemeID) + if _, err := s.execBuilder(tx, builder); err != nil { + return "", errors.Wrap(err, "unable_to_save_space_custom_scheme_role") + } + return roleName, nil +} + +// joinRolePermissions renders permissions as core's Roles.Permissions column shape: a leading- +// space-joined string (NewRoleFromModel's convention), read back with strings.Fields. +func joinRolePermissions(permissions []string) string { + return " " + strings.Join(permissions, " ") +} + +// DeleteSpaceCustomSchemeIfUnreferenced deletes a space-private custom scheme and its three +// generated roles once no channel — regardless of DeleteAt, since a soft-deleted space is +// restorable and keeps its SchemeId — still references it. excludeChannelID, when non-empty, is +// omitted from that reference count: space creation archives its backing channel when a later +// step fails, and an archived channel keeps its SchemeId, so without the exclusion the abandoned +// channel would count as a live reference and the scheme could never be retired. A no-op (not an +// error) when the scheme is still referenced. Returns ErrInvalidInput for a scheme this store did +// not create. +func (s *Store) DeleteSpaceCustomSchemeIfUnreferenced(schemeID, excludeChannelID string) (err error) { + if schemeID == "" { + return &ErrInvalidInput{Entity: "Scheme", Field: "id", Value: schemeID} + } + + tx, cancel, err := s.beginTxBounded() + if err != nil { + return errors.Wrap(err, "begin_transaction") + } + defer cancel() + defer s.finalizeTransaction(tx, &err) + + var scheme struct { + Name string + DefaultChannelUserRole string + DefaultChannelAdminRole string + DefaultChannelGuestRole string + } + schemeQuery := s.getQueryBuilder(). + Select("Name", "DefaultChannelUserRole", "DefaultChannelAdminRole", "DefaultChannelGuestRole"). + From("Schemes"). + Where(sq.Eq{"Id": schemeID}) + if txErr := s.getBuilder(tx, &scheme, schemeQuery); txErr != nil { + if errors.Is(txErr, sql.ErrNoRows) { + return &ErrNotFound{EntityName: "Scheme", ID: schemeID} + } + return errors.Wrap(txErr, "failed to read scheme for retirement") + } + // Two independent conditions must hold before anything is deleted: the name must carry this + // store's own namespace with a non-empty suffix (proving this store created it), and it must + // not be one of core's seeded preset names. + if len(scheme.Name) <= len(spaceCustomSchemeNamePrefix) || + !strings.HasPrefix(scheme.Name, spaceCustomSchemeNamePrefix) || + mmmodel.IsSpaceSchemeName(scheme.Name) { + return &ErrInvalidInput{Entity: "Scheme", Field: "name", Value: scheme.Name} + } + + var referenced int + refQuery := s.getQueryBuilder().Select("COUNT(*)").From("Channels").Where(sq.Eq{"SchemeId": schemeID}) + if excludeChannelID != "" { + refQuery = refQuery.Where(sq.NotEq{"Id": excludeChannelID}) + } + if txErr := s.getBuilder(tx, &referenced, refQuery); txErr != nil { + return errors.Wrap(txErr, "failed to count channels referencing scheme") + } + if referenced > 0 { + return nil + } + + roleNames := []string{scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole, scheme.DefaultChannelGuestRole} + deleteRoles := s.getQueryBuilder().Delete("Roles").Where(sq.Eq{"Name": roleNames}) + if _, txErr := s.execBuilder(tx, deleteRoles); txErr != nil { + return errors.Wrap(txErr, "failed to delete space custom scheme roles") + } + + deleteScheme := s.getQueryBuilder().Delete("Schemes").Where(sq.Eq{"Id": schemeID}) + if _, txErr := s.execBuilder(tx, deleteScheme); txErr != nil { + return errors.Wrap(txErr, "failed to delete space custom scheme") + } + + if err = tx.Commit(); err != nil { + return errors.Wrap(err, "commit_transaction") + } + return nil +} diff --git a/server/store/scheme_store_test.go b/server/store/scheme_store_test.go new file mode 100644 index 0000000..404738d --- /dev/null +++ b/server/store/scheme_store_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package store_test + +import ( + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// TestSpaceCustomScheme_RoundTrip verifies CreateSpaceCustomScheme -> GetSchemeRolesForChannel -> +// GetRolePermissionsByName: a custom scheme's generated user-role permissions are readable back +// exactly once a channel is repointed at it (mirroring SetSpaceDefaultCapabilities' repoint). +func TestSpaceCustomScheme_RoundTrip(t *testing.T) { + s, db := testutil.OpenTestStore(t) + + userPerms := []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionCommentPage.Id} + adminPerms := []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id} + guestPerms := []string{mmmodel.PermissionReadPage.Id} + + schemeID, err := s.CreateSpaceCustomScheme(userPerms, adminPerms, guestPerms) + require.NoError(t, err) + require.NotEmpty(t, schemeID) + + channelID := mmmodel.NewId() + _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) + require.NoError(t, err) + + roles, err := s.GetSchemeRolesForChannel(channelID) + require.NoError(t, err) + require.Equal(t, schemeID, roles.SchemeId) + require.NotEmpty(t, roles.UserRoleName) + require.NotEmpty(t, roles.AdminRoleName) + require.NotEmpty(t, roles.GuestRoleName) + + userRolePerms, err := s.GetRolePermissionsByName(roles.UserRoleName) + require.NoError(t, err) + require.ElementsMatch(t, userPerms, userRolePerms) + + adminRolePerms, err := s.GetRolePermissionsByName(roles.AdminRoleName) + require.NoError(t, err) + require.ElementsMatch(t, adminPerms, adminRolePerms) + + guestRolePerms, err := s.GetRolePermissionsByName(roles.GuestRoleName) + require.NoError(t, err) + require.ElementsMatch(t, guestPerms, guestRolePerms) +} + +// TestDeleteSpaceCustomSchemeIfUnreferenced covers the three outcomes: a no-op while a channel +// still references the scheme, an actual delete once unreferenced, and a rejection when the +// scheme id names one of the three seeded presets rather than a space-private custom scheme. +func TestDeleteSpaceCustomSchemeIfUnreferenced(t *testing.T) { + s, db := testutil.OpenTestStore(t) + + t.Run("no-op while referenced", func(t *testing.T) { + schemeID, err := s.CreateSpaceCustomScheme( + []string{mmmodel.PermissionReadPage.Id}, + []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, + []string{mmmodel.PermissionReadPage.Id}, + ) + require.NoError(t, err) + + channelID := mmmodel.NewId() + _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, "")) + + // Still readable: the delete was a no-op. + roles, err := s.GetSchemeRolesForChannel(channelID) + require.NoError(t, err) + require.Equal(t, schemeID, roles.SchemeId) + }) + + t.Run("deletes when the only reference is the excluded channel", func(t *testing.T) { + schemeID, err := s.CreateSpaceCustomScheme( + []string{mmmodel.PermissionReadPage.Id}, + []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, + []string{mmmodel.PermissionReadPage.Id}, + ) + require.NoError(t, err) + + // Space creation archives its backing channel when a later step fails, and an archived + // channel keeps its SchemeId — the reference count is deliberately DeleteAt-blind, so the + // row stands in for the archived channel. Without the exclusion that abandoned channel + // counts as a live reference and the scheme is orphaned forever. + channelID := mmmodel.NewId() + _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, channelID)) + + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, schemeID).Scan(&count)) + require.Zero(t, count, "the abandoned backing channel must not count as a live reference") + }) + + t.Run("still a no-op when another channel references the scheme", func(t *testing.T) { + schemeID, err := s.CreateSpaceCustomScheme( + []string{mmmodel.PermissionReadPage.Id}, + []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, + []string{mmmodel.PermissionReadPage.Id}, + ) + require.NoError(t, err) + + abandonedID, otherID := mmmodel.NewId(), mmmodel.NewId() + _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2), ($3, $2)`, abandonedID, schemeID, otherID) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, abandonedID)) + + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, schemeID).Scan(&count)) + require.Equal(t, 1, count, "excluding one channel must not delete a scheme another channel still uses") + }) + + t.Run("deletes when unreferenced", func(t *testing.T) { + schemeID, err := s.CreateSpaceCustomScheme( + []string{mmmodel.PermissionReadPage.Id}, + []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, + []string{mmmodel.PermissionReadPage.Id}, + ) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, "")) + + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, schemeID).Scan(&count)) + require.Zero(t, count, "the scheme row must be gone once unreferenced") + }) + + t.Run("rejects a preset scheme id", func(t *testing.T) { + var presetID string + require.NoError(t, db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, mmmodel.SchemeNameSpaceContribute).Scan(&presetID)) + + err := s.DeleteSpaceCustomSchemeIfUnreferenced(presetID, "") + require.Error(t, err) + require.True(t, store.IsErrInvalidInput(err)) + + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, presetID).Scan(&count)) + require.Equal(t, 1, count, "a preset scheme must never be deleted") + }) +} diff --git a/server/store/space_store.go b/server/store/space_store.go index 3dcb9ff..478ca18 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -19,7 +19,7 @@ import ( ) var spaceSelectColumns = []string{ - "Id", "ChannelId", "TeamId", "CreatorId", "Title", "Description", "Icon", "Props", + "Id", "ChannelId", "TeamId", "CreatorId", "Title", "Description", "Icon", "Props", "ViewAccess", "CreateAt", "UpdateAt", "DeleteAt", "SortOrder", } @@ -43,7 +43,7 @@ func (s *Store) CreateSpace(space *model.Space) (*model.Space, error) { builder := s.getQueryBuilder(). Insert("DOCS_Space"). Columns(spaceSelectColumns...). - Values(space.Id, space.ChannelId, space.TeamId, space.CreatorId, space.Title, space.Description, space.Icon, space.GetProps(), + Values(space.Id, space.ChannelId, space.TeamId, space.CreatorId, space.Title, space.Description, space.Icon, space.GetProps(), space.ViewAccess, space.CreateAt, space.UpdateAt, space.DeleteAt, space.SortOrder) if _, err := s.execBuilder(s.db, builder); err != nil { @@ -81,13 +81,16 @@ func (s *Store) GetSpace(spaceID string, includeDeleted bool) (*model.Space, err return &space, nil } -// GetSpacesForTeam returns live spaces for the given team visible to userID, ordered by -// SortOrder ascending with CreateAt then Id as stable tie-breakers. Visibility is membership -// of the space's backing channel, resolved by a read-only join against core's ChannelMembers -// table: space ("S") channels are excluded from the generic channel-listing plugin APIs, so -// the caller cannot supply its visible-channel set. There is deliberately no unfiltered -// variant, so a listing can never bypass the membership filter. limit must be > 0. -func (s *Store) GetSpacesForTeam(teamID, userID string, offset, limit int) ([]*model.Space, error) { +// GetSpacesForTeam returns one page of the given team's live spaces visible to userID, ordered by +// SortOrder ascending with CreateAt then Id as stable tie-breakers. A space is visible when the +// caller is a member of its backing channel (a read-only EXISTS against core's ChannelMembers +// table: space ("S") channels are excluded from the generic channel-listing plugin APIs, so the +// caller cannot supply its visible-channel set), or when the space is ViewAccess='open' and +// callerHasOpenFallthrough is true — a single app-layer-computed boolean carrying the caller's +// team-active/read_public_channel/compliance-mode conjunct (see the app-layer read resolver); +// the store never evaluates permissions itself. There is deliberately no unfiltered variant, so a +// listing can never bypass this predicate. limit must be > 0. +func (s *Store) GetSpacesForTeam(teamID, userID string, callerHasOpenFallthrough bool, offset, limit int) ([]*model.Space, error) { if teamID == "" { return nil, &ErrInvalidInput{Entity: "Space", Field: "teamID", Value: teamID} } @@ -98,11 +101,14 @@ func (s *Store) GetSpacesForTeam(teamID, userID string, offset, limit int) ([]*m return nil, err } + memberExists := sq.Expr("EXISTS (SELECT 1 FROM ChannelMembers cm WHERE cm.ChannelId = sp.ChannelId AND cm.UserId = ?)", userID) + openFallthrough := sq.And{sq.Eq{"sp.ViewAccess": model.ViewAccessOpen}, sq.Expr("?", callerHasOpenFallthrough)} + builder := s.getQueryBuilder(). Select(columnsWithAlias("sp", spaceSelectColumns)...). From("DOCS_Space sp"). - Join("ChannelMembers cm ON cm.ChannelId = sp.ChannelId"). - Where(sq.Eq{"sp.TeamId": teamID, "sp.DeleteAt": 0, "cm.UserId": userID}). + Where(sq.Eq{"sp.TeamId": teamID, "sp.DeleteAt": 0}). + Where(sq.Or{memberExists, openFallthrough}). OrderBy("sp.SortOrder ASC", "sp.CreateAt DESC", "sp.Id ASC") builder = applyLimitOffset(builder, offset, limit) @@ -130,10 +136,11 @@ func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpd return nil, &ErrInvalidInput{Entity: "Space", Field: "Patch", Value: validErr.Error(), Reason: validErr.Id} } - tx, err := s.db.Beginx() + tx, cancel, err := s.beginTxBounded() if err != nil { return nil, errors.Wrap(err, "begin_transaction") } + defer cancel() defer s.finalizeTransaction(tx, &err) var existing model.Space @@ -167,6 +174,7 @@ func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpd Set("Description", existing.Description). Set("Icon", existing.Icon). Set("Props", existing.GetProps()). + Set("ViewAccess", existing.ViewAccess). Set("UpdateAt", existing.UpdateAt). Where(sq.Eq{"Id": existing.Id, "DeleteAt": 0}) @@ -355,13 +363,18 @@ const ( // WithSpaceMembershipLock runs fn while holding spaceID's membership advisory lock, serializing // membership mutations for one space across processes. Guards that span multiple non-database // calls — read the member list, then mutate it — are atomic with respect to each other only -// under this lock. The lock is session-scoped on a dedicated pooled connection, with no open -// transaction: fn does no database work of its own, so wrapping it in a transaction would only -// expose the guard to idle-in-transaction timeouts. The connection is still intentionally held -// for fn's whole duration — cross-process serialization of a read-modify-write that spans -// non-database calls has no cheaper primitive — so fn must stay short. fn's error is returned -// unchanged. When the lock cannot be acquired within spaceMembershipLockAcquireTimeout, -// ErrConflict is returned so the caller can surface a retryable conflict. +// under this lock. The lock is session-scoped on a dedicated pooled connection with no open +// transaction of its own. fn may perform store work of its own — reads, and its own transactions +// (e.g. the ViewAccess live-row compare and UpdateSpace's write, or the auto-join pre-step's +// reads) — so each in-flight caller holds two pooled connections for the critical section: this +// lock's session connection plus any connection fn's own transaction acquires. Any transaction fn +// opens must therefore bound its connection acquisition (see beginTxBounded), or a saturated pool +// can leave every lock holder waiting on a connection no other holder will release. fn must also +// stay short: the lock connection is held for fn's whole duration, and cross-process +// serialization of a read-modify-write that spans non-database calls has no cheaper primitive. +// fn's error is returned unchanged. When the lock cannot be acquired within +// spaceMembershipLockAcquireTimeout, ErrConflict is returned so the caller can surface a +// retryable conflict. func (s *Store) WithSpaceMembershipLock(spaceID string, fn func() error) error { return s.withSpaceMembershipLock(spaceID, spaceMembershipLockAcquireTimeout, fn) } @@ -398,7 +411,7 @@ func (s *Store) withSpaceMembershipLock(spaceID string, acquireTimeout time.Dura } } if ctx.Err() != nil { - return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID} + return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID, Reason: ReasonLockTimeout} } return errors.Wrap(lockErr, "failed to acquire space membership advisory lock") } @@ -407,7 +420,7 @@ func (s *Store) withSpaceMembershipLock(spaceID string, acquireTimeout time.Dura } select { case <-ctx.Done(): - return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID} + return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID, Reason: ReasonLockTimeout} case <-time.After(spaceMembershipLockRetryInterval): } } diff --git a/server/store/store.go b/server/store/store.go index ca19f12..3960f82 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -168,6 +168,22 @@ func (s *Store) finalizeTransaction(tx *sqlx.Tx, perr *error) { } } +// beginTxBounded starts a transaction whose connection acquisition is bounded by +// defaultQueryTimeout, and must be used by any transaction that can run while its caller already +// holds WithSpaceMembershipLock's dedicated connection. Such a caller needs a second pooled +// connection while holding one, so an unbounded acquisition can wait forever on a saturated pool +// while itself holding a connection that pool needs in order to drain. The returned cancel must be +// deferred by the caller: it bounds acquisition, and releasing it ends the transaction's context. +func (s *Store) beginTxBounded() (*sqlx.Tx, context.CancelFunc, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultQueryTimeout) + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + cancel() + return nil, nil, err + } + return tx, cancel, nil +} + // get executes a query and scans one row into dest. func (s *Store) get(e sqlx.ExtContext, dest any, query string, args ...any) error { query = e.Rebind(query) @@ -311,6 +327,7 @@ const ( ReasonMaxDepthExceeded = "max_depth_exceeded" ReasonSubtreeMaxDepthExceeded = "subtree_max_depth_exceeded" ReasonParentNotLive = "parent_not_live" + ReasonSubtreeNotOwned = "subtree_not_owned" ) func (e *ErrInvalidInput) Error() string { @@ -326,6 +343,11 @@ func IsErrInvalidInput(err error) bool { // ErrConflict is returned when a unique constraint is violated or a CAS check fails. type ErrConflict struct { Resource string + // Reason optionally distinguishes a structurally different conflict — currently only + // ReasonLockTimeout, for a WithSpaceMembershipLock acquisition timeout — from the default + // unique-constraint/CAS-mismatch conflict, so a caller can map it to its own status/error key + // instead of the shared 409 conflict mapping. + Reason string } func (e *ErrConflict) Error() string { @@ -338,6 +360,17 @@ func IsErrConflict(err error) bool { return errors.As(err, &e) } +// ReasonLockTimeout marks an ErrConflict raised by a WithSpaceMembershipLock acquisition timeout, +// distinct from the default CAS/unique-constraint conflict. +const ReasonLockTimeout = "lock_timeout" + +// IsErrLockTimeout reports whether err is an ErrConflict raised by a space-membership advisory +// lock acquisition timeout. +func IsErrLockTimeout(err error) bool { + var e *ErrConflict + return errors.As(err, &e) && e.Reason == ReasonLockTimeout +} + // ErrLimitExceeded is returned when a result set exceeds a hard size limit. type ErrLimitExceeded struct { Resource string diff --git a/server/store/store_test.go b/server/store/store_test.go index 2211803..eb7c063 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -820,44 +820,77 @@ func TestGetSpacesForTeam(t *testing.T) { testutil.MustAddChannelMember(t, db, chVisible, memberOfOne) t.Run("returns every team space whose backing channel the user belongs to", func(t *testing.T) { - spaces, err := s.GetSpacesForTeam(teamID, memberOfAll, 0, 100) + spaces, err := s.GetSpacesForTeam(teamID, memberOfAll, false, 0, 100) require.NoError(t, err) require.Len(t, spaces, 2) }) t.Run("filters to the user's channel memberships", func(t *testing.T) { - spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, 0, 100) + spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, false, 0, 100) require.NoError(t, err) require.Len(t, spaces, 1) require.Equal(t, visible.Id, spaces[0].Id) }) t.Run("user with no memberships gets an empty result", func(t *testing.T) { - spaces, err := s.GetSpacesForTeam(teamID, mmmodel.NewId(), 0, 100) + spaces, err := s.GetSpacesForTeam(teamID, mmmodel.NewId(), false, 0, 100) require.NoError(t, err) require.Empty(t, spaces) }) t.Run("pagination excludes hidden spaces before offset/limit", func(t *testing.T) { // Only 1 visible space; with per_page=10 and 2 total, hidden must not count toward has_more. - spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, 0, 10) + spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, false, 0, 10) require.NoError(t, err) require.Len(t, spaces, 1) }) t.Run("rejects empty userID", func(t *testing.T) { - _, err := s.GetSpacesForTeam(teamID, "", 0, 100) + _, err := s.GetSpacesForTeam(teamID, "", false, 0, 100) require.Error(t, err) require.True(t, store.IsErrInvalidInput(err)) }) t.Run("rejects non-positive limit", func(t *testing.T) { for _, limit := range []int{0, -1} { - _, err := s.GetSpacesForTeam(teamID, memberOfAll, 0, limit) + _, err := s.GetSpacesForTeam(teamID, memberOfAll, false, 0, limit) require.Error(t, err) require.True(t, store.IsErrInvalidInput(err), "limit=%d must return ErrInvalidInput; got %v", limit, err) } }) + + t.Run("open non-member space is included only with the open fall-through, and never a private one", func(t *testing.T) { + nonMember := mmmodel.NewId() + + openChannel := mmmodel.NewId() + openSpace := newSpace(openChannel) + openSpace.TeamId = teamID + openSpace.ViewAccess = model.ViewAccessOpen + _, err := s.CreateSpace(openSpace) + require.NoError(t, err) + + privateChannel := mmmodel.NewId() + privateSpace := newSpace(privateChannel) + privateSpace.TeamId = teamID + privateSpace.ViewAccess = model.ViewAccessPrivate + _, err = s.CreateSpace(privateSpace) + require.NoError(t, err) + + withFallthrough, err := s.GetSpacesForTeam(teamID, nonMember, true, 0, 100) + require.NoError(t, err) + var ids []string + for _, sp := range withFallthrough { + ids = append(ids, sp.Id) + } + require.Contains(t, ids, openSpace.Id, "an open space must be visible to a non-member with the fall-through") + require.NotContains(t, ids, privateSpace.Id, "a private space must never be visible via the open fall-through") + + withoutFallthrough, err := s.GetSpacesForTeam(teamID, nonMember, false, 0, 100) + require.NoError(t, err) + for _, sp := range withoutFallthrough { + require.NotEqual(t, openSpace.Id, sp.Id, "the open space must not appear when the caller lacks the fall-through") + } + }) } // TestGetSpacePages_NonPositiveLimit verifies that GetSpacePages rejects limit <= 0 From 0d414febbff6d2d867f6813884e332896cbdd679 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:43 +0200 Subject: [PATCH 04/28] Enforce subtree ownership on cross-space page moves --- server/store/page_move.go | 72 +++++++++++++++++++-------- server/store/page_move_test.go | 90 +++++++++++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 27 deletions(-) diff --git a/server/store/page_move.go b/server/store/page_move.go index 981f5df..a2e0f73 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -247,7 +247,12 @@ func (s *Store) reindexSiblingGroup(tx *sqlx.Tx, channelID, parentID, movedPageI // cycle-safety are all re-validated under lock, so the move is safe regardless of concurrent // operations between the caller's pre-checks and this call. Cross-owner resources // (page-comment Posts, FileInfo) are not re-homed here. -func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int) (_ *model.Page, priorParentID string, err error) { +// requiredOwnerID, when non-empty, requires every live page in the moved subtree to be owned by +// that user, failing the move with ErrInvalidInput otherwise; empty skips the check. Checked here, +// against the transaction's own locked subtree read, to close the TOCTOU window a pre-scan would +// leave against a concurrent reparent grafting another user's page into the subtree between check +// and move. +func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int, requiredOwnerID string) (_ *model.Page, priorParentID string, err error) { if pageID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} } @@ -315,13 +320,22 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par newParentID = *parentPageID } - // Collect the live subtree ids (the root is included — every node is re-homed) along with the - // subtree's max relative depth, reused by the depth-cap check below instead of a second walk. - ids, subtreeMax, e := s.collectLiveSubtreeIDs(tx, pageID) + // Collect the live subtree ids (the root is included — every node is re-homed) along with each + // row's UserId and the subtree's max relative depth, reused by the depth-cap check below + // instead of a second walk. + ids, owners, subtreeMax, e := s.collectLiveSubtreeIDs(tx, pageID) if e != nil { return nil, "", e } + if requiredOwnerID != "" { + for _, id := range ids { + if owners[id] != requiredOwnerID { + return nil, "", &ErrInvalidInput{Entity: "Page", Field: "subtree_owner", Reason: ReasonSubtreeNotOwned} + } + } + } + if newParentID != "" { // Cycle guard, checked under the held locks against the just-collected subtree: the // destination parent may not be the moved page or one of its descendants. Checked before @@ -385,41 +399,59 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par return &moved, page.ParentId, nil } -// collectLiveSubtreeIDs returns the ids of pageID's whole live subtree (the root included) plus -// the subtree's max relative depth (levels below the root, 0 for a leaf — the same value -// pageSubtreeMaxDepth computes, read here at no extra cost), run within tx so it observes locked, -// uncommitted state. It errors — rather than silently truncating — when the subtree exceeds -// MaxPageDescendantsLimit or MaxPageHierarchyDepth (see pageSubtreeCTE for the recursion/cap -// mechanics). -func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int, error) { - subtreeCTE := pageSubtreeCTE + fmt.Sprintf(` - SELECT Id, depth FROM page_subtree ORDER BY depth, Id LIMIT %d`, MaxPageDescendantsLimit+2) +// pageSubtreeWithOwnerCTE mirrors pageSubtreeCTE (page_hierarchy.go) but also carries each +// subtree row's UserId, so collectLiveSubtreeIDs can resolve subtree-wide ownership in the same +// walk. Kept as a local variant here, rather than widening the shared CTE, so pageSubtreeCTE's +// other caller (pageSubtreeMaxDepth) is unaffected. +var pageSubtreeWithOwnerCTE = fmt.Sprintf(` + WITH RECURSIVE page_subtree AS ( + SELECT Id, UserId, 0 AS depth, ARRAY[Id]::text[] AS path + FROM DOCS_Page WHERE Id = $1 AND DeleteAt = 0 AND OriginalId = '' + UNION ALL + SELECT p.Id, p.UserId, ps.depth + 1, ps.path || p.Id + FROM DOCS_Page p + INNER JOIN page_subtree ps ON p.ParentId = ps.Id + WHERE p.DeleteAt = 0 AND p.OriginalId = '' AND ps.depth <= %d + AND NOT (p.Id = ANY(ps.path)) + )`, MaxPageHierarchyDepth) + +// collectLiveSubtreeIDs returns the ids of pageID's whole live subtree (the root included), each +// id's owning UserId, and the subtree's max relative depth (levels below the root, 0 for a leaf — +// the same value pageSubtreeMaxDepth computes, read here at no extra cost), run within tx so it +// observes locked, uncommitted state. It errors — rather than silently truncating — when the +// subtree exceeds MaxPageDescendantsLimit or MaxPageHierarchyDepth. +func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, map[string]string, int, error) { + subtreeCTE := pageSubtreeWithOwnerCTE + fmt.Sprintf(` + SELECT Id, UserId, depth FROM page_subtree ORDER BY depth, Id LIMIT %d`, MaxPageDescendantsLimit+2) var subtreeRows []struct { - ID string - Depth int + ID string + UserId string + Depth int } if e := s.selectAll(tx, &subtreeRows, subtreeCTE, pageID); e != nil { - return nil, 0, errors.Wrap(e, "failed to collect page subtree") + return nil, nil, 0, errors.Wrap(e, "failed to collect page subtree") } if len(subtreeRows) == 0 { - return nil, 0, &ErrNotFound{EntityName: "Page", ID: pageID} + return nil, nil, 0, &ErrNotFound{EntityName: "Page", ID: pageID} } // Exclude the root from the descendant-count cap (subtreeRows always holds at least the root). if len(subtreeRows)-1 > MaxPageDescendantsLimit { - return nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (size)", Limit: MaxPageDescendantsLimit} + return nil, nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (size)", Limit: MaxPageDescendantsLimit} } ids := make([]string, 0, len(subtreeRows)) + owners := make(map[string]string, len(subtreeRows)) maxRelDepth := 0 for _, row := range subtreeRows { if row.Depth > MaxPageHierarchyDepth { - return nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (depth)", Limit: MaxPageHierarchyDepth} + return nil, nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (depth)", Limit: MaxPageHierarchyDepth} } if row.Depth > maxRelDepth { maxRelDepth = row.Depth } ids = append(ids, row.ID) + owners[row.ID] = row.UserId } - return ids, maxRelDepth, nil + return ids, owners, maxRelDepth, nil } // rewriteSubtreeSpace re-homes the given page IDs onto diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index 0436c79..383ca16 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -120,7 +120,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth) + movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.NoError(t, err) require.Equal(t, spaceB.Id, movedRoot.SpaceId, "returned page reflects the committed move") require.Equal(t, chB, movedRoot.ChannelId) @@ -167,7 +167,7 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) require.Len(t, sourceBefore, 2, "both drafts are readable in the source space before the move") - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.NoError(t, err) movedDraft, err := s.GetDraft(user, page.Id) @@ -185,7 +185,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty pageID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -193,7 +193,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty sourceSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -201,7 +201,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty targetSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -213,7 +213,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) require.True(t, store.IsErrNotFound(err), "expected ErrNotFound, got %T: %v", err, err) }) @@ -414,7 +414,7 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { }) t.Run("move-to-space with wrong source space is not found", func(t *testing.T) { - _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.True(t, store.IsErrNotFound(mErr)) }) @@ -450,3 +450,79 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { require.Equal(t, child.Id, descendants[0].Id) }) } + +// TestMovePageToSpace_RequiredOwnerID covers requiredOwnerID: a mechanical in-transaction +// ownership check the app layer supplies only when it resolved the caller to the own-scoped +// delete_own_page gate. A subtree with any live page owned by someone else is rejected; a subtree +// wholly owned by requiredOwnerID succeeds; an empty requiredOwnerID ignores ownership entirely. +func TestMovePageToSpace_RequiredOwnerID(t *testing.T) { + t.Run("descendant owned by a different user fails with subtree_not_owned", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + owner := mmmodel.NewId() + root, err := s.CreatePage(newPage(spaceA.Id, chA, owner, ""), testDefaultMaxDepth) + require.NoError(t, err) + _, err = s.CreatePage(newPage(spaceA.Id, chA, mmmodel.NewId(), root.Id), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + _, _, err = s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, owner) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonSubtreeNotOwned, inv.Reason) + + // The rejected move must leave the subtree in place. + gotRoot, gErr := s.GetPage(root.Id, false) + require.NoError(t, gErr) + require.Equal(t, spaceA.Id, gotRoot.SpaceId) + }) + + t.Run("subtree wholly owned by requiredOwnerID succeeds", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + owner := mmmodel.NewId() + root, err := s.CreatePage(newPage(spaceA.Id, chA, owner, ""), testDefaultMaxDepth) + require.NoError(t, err) + child, err := s.CreatePage(newPage(spaceA.Id, chA, owner, root.Id), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + moved, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, owner) + require.NoError(t, err) + require.Equal(t, spaceB.Id, moved.SpaceId) + + gotChild, gErr := s.GetPage(child.Id, false) + require.NoError(t, gErr) + require.Equal(t, spaceB.Id, gotChild.SpaceId, "the owned child follows the subtree") + }) + + t.Run("empty requiredOwnerID ignores ownership", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + root, err := s.CreatePage(newPage(spaceA.Id, chA, mmmodel.NewId(), ""), testDefaultMaxDepth) + require.NoError(t, err) + _, err = s.CreatePage(newPage(spaceA.Id, chA, mmmodel.NewId(), root.Id), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + moved, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, "") + require.NoError(t, err) + require.Equal(t, spaceB.Id, moved.SpaceId) + }) +} From fd72f5c7d433b968f958c1416a1556c3d3afb533 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:53 +0200 Subject: [PATCH 05/28] Add permission stubs and fixtures for space gates --- server/internal/testutil/fixtures.go | 128 ++++++++++++++++++++++++++- server/internal/testutil/permstub.go | 45 ++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 server/internal/testutil/permstub.go diff --git a/server/internal/testutil/fixtures.go b/server/internal/testutil/fixtures.go index 4b1142c..a59cb93 100644 --- a/server/internal/testutil/fixtures.go +++ b/server/internal/testutil/fixtures.go @@ -5,6 +5,7 @@ package testutil import ( "database/sql" + "strings" "testing" "github.com/stretchr/testify/require" @@ -44,9 +45,127 @@ func OpenTestStore(t *testing.T) (*store.Store, *sql.DB) { )`) require.NoError(t, err, "create ChannelMembers stand-in") + // The RBAC capability model resolves a space's default capability set through its backing + // channel's core Channels/Schemes/Roles rows (GetSchemeRolesForChannel, GetSchemeIdByName, + // GetRolePermissionsByName) — core tables owned by the paired core branch's migration in + // production, absent from this plugin-only isolated schema. Stand them in with just the + // columns the plugin store queries, and seed the three preset schemes (what the core seeding migration, + // mirrored here) so scheme-resolving app methods work against test-created spaces. + mustCreateSchemeStandInTables(t, db) + mustSeedSpaceSchemes(t, db) + return s, db } +// mustCreateSchemeStandInTables creates the core Channels/Schemes/Roles stand-in tables with only +// the columns the plugin store reads or writes (see space_store.go/scheme_store.go). +func mustCreateSchemeStandInTables(t *testing.T, db *sql.DB) { + t.Helper() + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS Channels ( + Id varchar(26) PRIMARY KEY, + SchemeId varchar(26) + )`) + require.NoError(t, err, "create Channels stand-in") + + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS Schemes ( + Id varchar(26) PRIMARY KEY, + Name varchar(64) UNIQUE NOT NULL, + DisplayName varchar(128) NOT NULL DEFAULT '', + Description varchar(1024) NOT NULL DEFAULT '', + Scope varchar(32) NOT NULL DEFAULT '', + DefaultTeamAdminRole varchar(64) NOT NULL DEFAULT '', + DefaultTeamUserRole varchar(64) NOT NULL DEFAULT '', + DefaultTeamGuestRole varchar(64) NOT NULL DEFAULT '', + DefaultChannelAdminRole varchar(64) NOT NULL DEFAULT '', + DefaultChannelUserRole varchar(64) NOT NULL DEFAULT '', + DefaultChannelGuestRole varchar(64) NOT NULL DEFAULT '', + CreateAt bigint NOT NULL DEFAULT 0, + UpdateAt bigint NOT NULL DEFAULT 0, + DeleteAt bigint NOT NULL DEFAULT 0, + DefaultPlaybookAdminRole varchar(64) NOT NULL DEFAULT '', + DefaultPlaybookMemberRole varchar(64) NOT NULL DEFAULT '', + DefaultRunAdminRole varchar(64) NOT NULL DEFAULT '', + DefaultRunMemberRole varchar(64) NOT NULL DEFAULT '' + )`) + require.NoError(t, err, "create Schemes stand-in") + + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS Roles ( + Id varchar(26) PRIMARY KEY, + Name varchar(64) UNIQUE NOT NULL, + DisplayName varchar(128) NOT NULL DEFAULT '', + Description varchar(1024) NOT NULL DEFAULT '', + Permissions text NOT NULL DEFAULT '', + CreateAt bigint NOT NULL DEFAULT 0, + UpdateAt bigint NOT NULL DEFAULT 0, + DeleteAt bigint NOT NULL DEFAULT 0, + SchemeManaged boolean NOT NULL DEFAULT false, + BuiltIn boolean NOT NULL DEFAULT false, + SchemeId varchar(26) + )`) + require.NoError(t, err, "create Roles stand-in") +} + +// mustSeedSpaceSchemes seeds the three preset space schemes (contribute/comment/read-only), each +// with a generated user/admin/guest role carrying the canonical permission sets — the same shape +// the paired core branch's core seeding migration creates in production. +func mustSeedSpaceSchemes(t *testing.T, db *sql.DB) { + t.Helper() + presets := []struct { + name string + user []*mmmodel.Permission + admin []*mmmodel.Permission + guest []*mmmodel.Permission + }{ + {mmmodel.SchemeNameSpaceContribute, mmmodel.SpaceDefaultContributePermissions, mmmodel.SpaceAdminRolePermissions, []*mmmodel.Permission{mmmodel.PermissionReadPage}}, + {mmmodel.SchemeNameSpaceComment, mmmodel.SpaceDefaultCommentPermissions, mmmodel.SpaceAdminRolePermissions, []*mmmodel.Permission{mmmodel.PermissionReadPage}}, + {mmmodel.SchemeNameSpaceReadOnly, mmmodel.SpaceDefaultReadOnlyPermissions, mmmodel.SpaceAdminRolePermissions, []*mmmodel.Permission{mmmodel.PermissionReadPage}}, + } + now := mmmodel.GetMillis() + for _, p := range presets { + userRoleName := mustInsertStandInRole(t, db, "User Role for "+p.name, p.user, now) + adminRoleName := mustInsertStandInRole(t, db, "Admin Role for "+p.name, p.admin, now) + guestRoleName := mustInsertStandInRole(t, db, "Guest Role for "+p.name, p.guest, now) + + _, err := db.Exec(`INSERT INTO Schemes + (Id, Name, Scope, DefaultChannelUserRole, DefaultChannelAdminRole, DefaultChannelGuestRole, CreateAt, UpdateAt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`, + mmmodel.NewId(), p.name, mmmodel.SchemeScopeChannel, userRoleName, adminRoleName, guestRoleName, now) + require.NoError(t, err, "seed space scheme %q", p.name) + } +} + +// mustInsertStandInRole inserts one generated role row carrying permissions, returning its +// (randomly generated) name — mirroring core's scheme-generated role shape closely enough for the +// plugin store's Permissions-column parsing (space-joined string, strings.Fields on read). +func mustInsertStandInRole(t *testing.T, db *sql.DB, displayName string, permissions []*mmmodel.Permission, now int64) string { + t.Helper() + ids := make([]string, 0, len(permissions)) + for _, p := range permissions { + ids = append(ids, p.Id) + } + roleName := mmmodel.NewId() + _, err := db.Exec(`INSERT INTO Roles (Id, Name, DisplayName, Permissions, CreateAt, UpdateAt, SchemeManaged, BuiltIn) + VALUES ($1, $2, $3, $4, $5, $5, true, false)`, + mmmodel.NewId(), roleName, displayName, " "+strings.Join(ids, " "), now) + require.NoError(t, err, "seed stand-in role %q", displayName) + return roleName +} + +// MustSeedChannelScheme seeds (or repoints) a Channels stand-in row so channelID resolves to the +// named seeded space scheme preset (see model.SpaceSchemeNames) — production sets this via the +// real Channels table when CreateSpace/SetSpaceDefaultCapabilities points a space's backing +// channel at a scheme; tests that mock the backing channel (CreateChannel via plugintest) need +// this seeded explicitly, since the mock never writes to a real Channels table. +func MustSeedChannelScheme(t *testing.T, db *sql.DB, channelID, schemeName string) { + t.Helper() + var schemeID string + err := db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, schemeName).Scan(&schemeID) + require.NoError(t, err, "look up seeded scheme %q", schemeName) + _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2) + ON CONFLICT (Id) DO UPDATE SET SchemeId = EXCLUDED.SchemeId`, channelID, schemeID) + require.NoError(t, err, "seed Channels stand-in for %q", channelID) +} + // MustAddChannelMember seeds a ChannelMembers row (see the stand-in table in OpenTestStore) so // store queries that resolve visibility through channel membership can see channelID as userID. func MustAddChannelMember(t *testing.T, db *sql.DB, channelID, userID string) { @@ -59,10 +178,11 @@ func MustAddChannelMember(t *testing.T, db *sql.DB, channelID, userID string) { // creator. func NewSpace(channelID, teamID string) *model.Space { return &model.Space{ - ChannelId: channelID, - TeamId: teamID, - CreatorId: mmmodel.NewId(), - Title: "Test Space", + ChannelId: channelID, + TeamId: teamID, + CreatorId: mmmodel.NewId(), + Title: "Test Space", + ViewAccess: model.ViewAccessOpen, } } diff --git a/server/internal/testutil/permstub.go b/server/internal/testutil/permstub.go new file mode 100644 index 0000000..ed6c001 --- /dev/null +++ b/server/internal/testutil/permstub.go @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package testutil + +import ( + "github.com/stretchr/testify/mock" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" +) + +// StubDefaultSpacePermissions registers .Maybe() stubs on mockAPI granting every user the +// "ordinary contribute-preset space member" channel permissions plus the team-baseline +// permissions every active team member holds by default (read_space, create_space, +// read_public_channel) — mirroring production's default create posture (open + contribute) so +// tests that assume plain backing-channel membership is sufficient need no per-test permission +// wiring. admin_space/delete_page (channel) and manage_space/delete_space (team) and the system +// manage_system override default to false. +// +// A test exercising an elevation-gated route (manage/admin/sysadmin) registers its own specific +// HasPermissionTo*/HasPermissionToTeam/HasPermissionToChannel expectation for the acting user +// BEFORE calling the harness constructor that invokes this — mock.Mock matches expectations in +// registration order, so the more specific, earlier-registered stub takes precedence over these +// catch-alls. +func StubDefaultSpacePermissions(mockAPI *plugintest.API) { + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadPage, mmmodel.PermissionCreatePage, mmmodel.PermissionCommentPage, + mmmodel.PermissionEditPage, mmmodel.PermissionDeleteOwnPage, + } { + mockAPI.On("HasPermissionToChannel", mock.Anything, mock.Anything, p).Return(true).Maybe() + } + for _, p := range []*mmmodel.Permission{mmmodel.PermissionDeletePage, mmmodel.PermissionAdminSpace} { + mockAPI.On("HasPermissionToChannel", mock.Anything, mock.Anything, p).Return(false).Maybe() + } + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadSpace, mmmodel.PermissionCreateSpace, mmmodel.PermissionReadPublicChannel, + } { + mockAPI.On("HasPermissionToTeam", mock.Anything, mock.Anything, p).Return(true).Maybe() + } + for _, p := range []*mmmodel.Permission{mmmodel.PermissionManageSpace, mmmodel.PermissionDeleteSpace} { + mockAPI.On("HasPermissionToTeam", mock.Anything, mock.Anything, p).Return(false).Maybe() + } + mockAPI.On("HasPermissionTo", mock.Anything, mmmodel.PermissionManageSystem).Return(false).Maybe() +} From 73db0f223b210dcda2ca8852cd93222d22cc6628 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:53 +0200 Subject: [PATCH 06/28] Add space permission resolution, auto-join and membership capabilities --- server/app/page_hierarchy.go | 10 +- server/app/permissions.go | 313 +++++++++++++++++++++ server/app/service.go | 8 + server/app/space.go | 517 +++++++++++++++++++++++------------ server/app/space_members.go | 356 ++++++++++++++++++++++++ server/app/ws_events.go | 33 ++- 6 files changed, 1042 insertions(+), 195 deletions(-) create mode 100644 server/app/permissions.go create mode 100644 server/app/space_members.go diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 8c938e1..90ce878 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -162,9 +162,11 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID // A nil expectedUpdateAt without force is rejected: the mutation must supply a baseline. // sourceSpace and targetSpace are the caller's already-fetched records (from its membership // gates), so no re-read happens here. userID is the acting user, recorded in logs only — a -// move does not change the page's LastModifiedBy. Per-page restrictions and redirects are not -// handled yet. -func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID string) (*model.Page, *mmmodel.AppError) { +// move does not change the page's LastModifiedBy. requiredOwnerID, when non-empty, requires +// every live page in the moved subtree to be owned by it — the gate resolves this to userID on +// the delete_own_page-only path and "" on the delete_page (any) path; a mismatch fails the move +// in-transaction. Per-page restrictions and redirects are not handled yet. +func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID, requiredOwnerID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.invalid_id.app_error", nil, "", http.StatusBadRequest) } @@ -231,7 +233,7 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model s.log.Debug("Moving page to space", "page_id", pageID, "source_space_id", sourceSpace.Id, "target_space_id", targetSpace.Id, "user_id", userID) - moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) + moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth, requiredOwnerID) if storeErr != nil { return nil, storeAppError("MovePageToSpace", storeErr) } diff --git a/server/app/permissions.go b/server/app/permissions.go new file mode 100644 index 0000000..146f7e3 --- /dev/null +++ b/server/app/permissions.go @@ -0,0 +1,313 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// ReadResolution reports how a read was admitted, so callers (notably the auto-join pre-step) +// can tell a real membership/sysadmin read from the non-member open-space fall-through. +type ReadResolution int + +const ( + ReadDenied ReadResolution = iota + ReadViaSysadmin + ReadViaMember + ReadViaOpenFallthrough +) + +// existenceHidingForbidden is the shared 403 every enforcement helper returns on a lookup miss +// or a denied check, so a caller cannot distinguish "doesn't exist", "not a member", and "no +// longer a member" by status code or message. +func existenceHidingForbidden(where string) *mmmodel.AppError { + return mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden) +} + +// complianceModeOff reports whether ComplianceSettings.Enable is unset or false. A nil client +// (or nil config) is treated as compliance-off, matching core's own SafeDereference default. +func (s *Service) complianceModeOff() bool { + if s.client == nil { + return true + } + cfg := s.client.Configuration.GetConfig() + if cfg == nil { + return true + } + return !mmmodel.SafeDereference(cfg.ComplianceSettings.Enable) +} + +// openTeamFallthrough reports whether userID holds the non-member team read_public_channel +// fall-through into teamID's open spaces. Suppressed under compliance mode. +func (s *Service) openTeamFallthrough(userID, teamID string) bool { + return s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadPublicChannel) && s.complianceModeOff() +} + +// readResolutionFrom evaluates the read gate from an already-resolved sysadmin/active pair, so a +// caller that has resolved them once (requireActiveMemberGate) never re-derives them: sysadmin +// override, then backing-channel membership, then — for an open space — the non-member team +// fall-through. +func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, userID string) ReadResolution { + if sysadmin { + return ReadViaSysadmin + } + if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionReadPage) { + return ReadViaMember + } + if space.ViewAccess == model.ViewAccessOpen && active && s.openTeamFallthrough(userID, space.TeamId) { + return ReadViaOpenFallthrough + } + return ReadDenied +} + +// ResolveSpaceRead resolves the read gate for space against userID, reporting how the read was +// admitted so callers can gate auto-join to the fall-through case only. where identifies the +// calling operation for the 500 an isActiveTeamMember lookup failure surfaces as — a genuine +// backend failure must never collapse into ReadDenied, which would misreport an outage as "not +// authorized". +func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID string) (ReadResolution, *mmmodel.AppError) { + if appErr := s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { + return ReadDenied, appErr + } + if space == nil { + return ReadDenied, existenceHidingForbidden(where) + } + if s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) { + return ReadViaSysadmin, nil + } + active, err := s.isActiveTeamMember(space.TeamId, userID) + if err != nil { + return ReadDenied, mmmodel.NewAppError(where, "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return s.readResolutionFrom(false, active, space, userID), nil +} + +// requireActiveMemberGate runs the four-gate preamble shared by every space-scoped Require* +// helper: client wiring, existence-hiding on a nil space, the sysadmin override, and +// active-team-membership resolution with its 500 on a genuine lookup failure. A non-nil appErr +// must be returned by the caller immediately. Otherwise, when sysadmin is true the caller may +// return nil immediately; when it is false the caller continues with active to evaluate its own +// permission-specific branches. +func (s *Service) requireActiveMemberGate(where string, space *model.Space, userID string) (active, sysadmin bool, appErr *mmmodel.AppError) { + if appErr = s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { + return false, false, appErr + } + if space == nil { + return false, false, existenceHidingForbidden(where) + } + if s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) { + return false, true, nil + } + active, err := s.isActiveTeamMember(space.TeamId, userID) + if err != nil { + return false, false, mmmodel.NewAppError(where, "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return active, false, nil +} + +// RequireSpacePagePermission gates a page-scoped operation on perm: sysadmin override, then +// backing-channel membership (plus active team membership) via HasPermissionToChannel, then — +// for a read permission on an open space only — the non-member fall-through. Any other case +// (including a nil space, mirroring a lookup miss upstream) yields the shared existence-hiding +// 403. +func (s *Service) RequireSpacePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission) *mmmodel.AppError { + active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return appErr + } + if sysadmin { + return nil + } + if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, perm) { + return nil + } + if perm.Id == mmmodel.PermissionReadPage.Id && space.ViewAccess == model.ViewAccessOpen && active && + s.openTeamFallthrough(userID, space.TeamId) { + return nil + } + return existenceHidingForbidden(where) +} + +// RequireSpacePagePermissionFrom is RequireSpacePagePermission for a caller that has already +// resolved the read gate for the same space and user via ResolveSpaceRead, so the team-membership +// lookup behind that resolution is not repeated. admittedVia must be a non-denied resolution; +// every such resolution other than ReadViaSysadmin already established active team membership. +// +// That team-active status is carried forward, not re-verified: a revocation landing between the +// admitting read and this call is not caught until the next call. The channel-scoped check below +// is always re-resolved, because the auto-join pre-step may have changed channel membership in +// between. +func (s *Service) RequireSpacePagePermissionFrom(where string, space *model.Space, userID string, perm *mmmodel.Permission, admittedVia ReadResolution) *mmmodel.AppError { + if admittedVia == ReadDenied { + return existenceHidingForbidden(where) + } + if admittedVia == ReadViaSysadmin { + return nil + } + if appErr := s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { + return appErr + } + if space == nil { + return existenceHidingForbidden(where) + } + if s.client.User.HasPermissionToChannel(userID, space.ChannelId, perm) { + return nil + } + if perm.Id == mmmodel.PermissionReadPage.Id && space.ViewAccess == model.ViewAccessOpen && + s.openTeamFallthrough(userID, space.TeamId) { + return nil + } + return existenceHidingForbidden(where) +} + +// requireChannelAdminOrTeamPerm gates an elevated space operation: sysadmin, channel admin_space +// (plus active team membership), or teamPerm on the space's team — the latter only once the read +// resolver has already admitted the caller, so a team-wide grant authorizes acting only on spaces +// the caller can already read. +func (s *Service) requireChannelAdminOrTeamPerm(where string, space *model.Space, userID string, teamPerm *mmmodel.Permission) *mmmodel.AppError { + active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return appErr + } + if sysadmin { + return nil + } + if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { + return nil + } + if s.readResolutionFrom(false, active, space, userID) != ReadDenied && + s.client.User.HasPermissionToTeam(userID, space.TeamId, teamPerm) { + return nil + } + return existenceHidingForbidden(where) +} + +// RequireSpaceManage gates a manage-tier space operation: sysadmin, channel admin_space, or team +// manage_space. +func (s *Service) RequireSpaceManage(where string, space *model.Space, userID string) *mmmodel.AppError { + return s.requireChannelAdminOrTeamPerm(where, space, userID, mmmodel.PermissionManageSpace) +} + +// RequireSpaceAdminOrSysadmin gates the space-wide exposure-policy knobs (ViewAccess, default +// capabilities) and admin-affecting member changes: sysadmin, or channel admin_space plus active +// team membership. No team-manage_space branch — those knobs are stricter than ordinary manage. +func (s *Service) RequireSpaceAdminOrSysadmin(where string, space *model.Space, userID string) *mmmodel.AppError { + active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return appErr + } + if sysadmin { + return nil + } + if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { + return nil + } + return existenceHidingForbidden(where) +} + +// RequireSpaceDeleteAuthority gates space delete/restore: sysadmin, channel admin_space, or team +// delete_space. +func (s *Service) RequireSpaceDeleteAuthority(where string, space *model.Space, userID string) *mmmodel.AppError { + return s.requireChannelAdminOrTeamPerm(where, space, userID, mmmodel.PermissionDeleteSpace) +} + +// WouldDefaultGrant reports whether space's current default capability set (the scheme's +// generated user role) grants perm to a plain member — the auto-join admission test. Channel +// without a scheme (ErrNotFound) reports false, not an error. +func (s *Service) WouldDefaultGrant(space *model.Space, perm *mmmodel.Permission) (bool, error) { + roles, err := s.store.GetSchemeRolesForChannel(space.ChannelId) + if err != nil { + if store.IsErrNotFound(err) { + return false, nil + } + return false, err + } + if s.client == nil { + return false, nil + } + return s.client.User.RolesGrantPermission([]string{roles.UserRoleName}, perm.Id), nil +} + +// spaceIDOrEmpty is a nil-safe accessor used only for log context. +func spaceIDOrEmpty(space *model.Space) string { + if space == nil { + return "" + } + return space.Id +} + +// AutoJoinIfDefaultGranted is the auto-join pre-step: when a non-member's write was admitted only via +// the open-space read fall-through (admittedVia == ReadViaOpenFallthrough) and the space's +// current default capability set grants perm to a plain member, it silently joins userID to the +// backing channel (idempotent) so the subsequent write-gate re-check passes as a member. The +// open-read admission is re-validated before joining: a concurrent open->private flip between the +// admitting read and this pre-step aborts the join. +// ownerCheck, when non-nil, must additionally hold (used for delete_own_page, where the caller +// must already own the target page before being joined). Returns whether a join happened. +func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, admittedVia ReadResolution, perm *mmmodel.Permission, ownerCheck func() (bool, error)) (bool, *mmmodel.AppError) { + if admittedVia != ReadViaOpenFallthrough { + return false, nil + } + if appErr := s.requireClient("AutoJoinIfDefaultGranted", "space_id", space.Id, "user_id", userID); appErr != nil { + return false, appErr + } + + joined := false + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + fresh, getErr := s.store.GetSpace(space.Id, false) + if getErr != nil { + if store.IsErrNotFound(getErr) { + return nil // Space vanished or was deleted concurrently: no join. + } + return mmmodel.NewAppError("AutoJoinIfDefaultGranted", "app.space.auto_join.get_space_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(getErr) + } + resolution, resErr := s.ResolveSpaceRead("AutoJoinIfDefaultGranted", fresh, userID) + if resErr != nil { + return resErr + } + if resolution != ReadViaOpenFallthrough { + return nil // Re-validation failed: e.g. the space flipped private concurrently. + } + granted, grantErr := s.WouldDefaultGrant(fresh, perm) + if grantErr != nil { + return grantErr + } + if !granted { + return nil + } + if ownerCheck != nil { + owns, ownErr := ownerCheck() + if ownErr != nil { + return ownErr + } + if !owns { + return nil + } + } + if _, memErr := s.client.Channel.GetMember(fresh.ChannelId, userID); memErr == nil { + return nil // Already a member. + } + member, addErr := s.client.Channel.AddMember(fresh.ChannelId, userID) + if addErr != nil { + return addErr + } + joined = true + s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": fresh.Id, "user_id": member.UserId}, fresh.ChannelId) + return nil + }) + if lockErr != nil { + var appErr *mmmodel.AppError + if errors.As(lockErr, &appErr) { + return false, appErr + } + return false, storeAppError("AutoJoinIfDefaultGranted", lockErr) + } + return joined, nil +} diff --git a/server/app/service.go b/server/app/service.go index 0ee955c..c6fcabc 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -118,6 +118,11 @@ func storeAppError(where string, err error) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.page.circular_reference.app_error", nil, "", http.StatusBadRequest).Wrap(err) case store.IsErrInvalidInput(err): return invalidInputAppError(where, err) + case store.IsErrLockTimeout(err): + // Distinct from the default CAS/unique-constraint conflict below: this is a + // WithSpaceMembershipLock acquisition timeout (another mutation on the same space is + // still in flight), not a stale-baseline write. + return mmmodel.NewAppError(where, "app.space.lock_timeout.app_error", nil, "", http.StatusConflict).Wrap(err) case store.IsErrConflict(err): return mmmodel.NewAppError(where, "app.store.conflict.app_error", nil, "", http.StatusConflict).Wrap(err) case store.IsErrLimitExceeded(err): @@ -149,6 +154,9 @@ func invalidInputAppError(where string, err error) *mmmodel.AppError { // identically to one that never existed — the contract is not race-dependent. return mmmodel.NewAppError(where, "app.page.invalid_parent.app_error", nil, "", http.StatusBadRequest).Wrap(err) } + if invErr.Reason == store.ReasonSubtreeNotOwned { + return mmmodel.NewAppError(where, "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } return mmmodel.NewAppError(where, invErr.Reason, nil, "", http.StatusBadRequest).Wrap(err) } return mmmodel.NewAppError(where, "app.store.invalid_input.app_error", nil, "", http.StatusBadRequest).Wrap(err) diff --git a/server/app/space.go b/server/app/space.go index a2335d0..57c7c10 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -6,6 +6,7 @@ package app import ( "errors" "net/http" + "slices" "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -109,27 +110,88 @@ func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID str // archiveOrphanChannel archives a backing channel when a later step in space creation fails, // to avoid an orphaned channel. reason describes the step that failed; cause is its error. -func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) { +// Reports whether the channel was archived, which decides whether the same failure path may +// exclude it from the custom scheme's reference count (see cleanupCustomScheme). +func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) bool { if s.client == nil { - return + return false } if delErr := s.client.Channel.Delete(channelID); delErr != nil { // The channel now exists with no space row pointing at it and nothing will retry this // archive, so it needs an operator to clean it up: log at Error, not Warn. s.log.Error("compensating channel archive failed; channel is orphaned and must be archived manually", "channel_id", channelID, "failure_reason", reason, "cause_err", cause, "delete_err", delErr) + return false + } + return true +} + +// resolveSpaceScheme resolves the backing-channel scheme id for a space-default capability set: +// a set matching one of the three seeded presets repoints to the shared preset scheme; any other +// set creates a new space-private immutable custom scheme. Returns the scheme id and +// whether a custom scheme was created (so the caller can retire it on a later create-step +// failure). +func (s *Service) resolveSpaceScheme(caps []string) (schemeID string, createdCustom bool, err error) { + // Normalize before the permission set is persisted: the validators are dedup-tolerant, so + // without this a request repeating one allowlisted token would write that repetition verbatim + // into the generated role's Permissions column. + caps = model.NormalizeCapabilitySet(caps) + if presetName, ok := model.SchemeNameForDefaultCapabilities(caps); ok { + id, getErr := s.store.GetSchemeIdByName(presetName) + if getErr != nil { + return "", false, getErr + } + return id, false, nil + } + userPerms := append([]string{model.CapabilityReadPage}, caps...) + adminPerms := mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions) + guestPerms := []string{model.CapabilityReadPage} + id, createErr := s.store.CreateSpaceCustomScheme(userPerms, adminPerms, guestPerms) + if createErr != nil { + return "", false, createErr + } + return id, true, nil +} + +// cleanupCustomScheme best-effort retires schemeID when createdCustom is true and a later +// create-step fails. abandonedChannelID, when non-empty, is the backing channel the same failure +// path just archived: archiving keeps the channel's SchemeId, so it must be excluded from the +// store's reference count or the retire would find the scheme still referenced and silently skip. +// It must be left empty when the archive did not succeed — excluding a channel that is still live +// would retire a scheme that channel still points at, leaving it with a dangling SchemeId. +func (s *Service) cleanupCustomScheme(schemeID string, createdCustom bool, abandonedChannelID string) { + if !createdCustom { + return + } + if err := s.store.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, abandonedChannelID); err != nil { + s.log.Warn("failed to retire unreferenced space custom scheme after a failed create", "scheme_id", schemeID, "err", err) } } +// abandonBackingChannel runs the compensating cleanup shared by every CreateSpace failure that +// happens after the backing channel exists: archive the channel, then retire the custom scheme it +// was created for. The scheme is only excluded from the reference count when the archive actually +// succeeded, so a channel left live by a failed archive still pins its scheme. +func (s *Service) abandonBackingChannel(channelID, reason string, cause error, schemeID string, createdCustom bool) { + archived := s.archiveOrphanChannel(channelID, reason, cause) + excluded := "" + if archived { + excluded = channelID + } + s.cleanupCustomScheme(schemeID, createdCustom, excluded) +} + // CreateSpace creates a ChannelTypeSpace ("S") backing channel via pluginapi, saves the -// space row pointing at it, and adds the creator as a member. space.ChannelId must be empty — -// it is set from the created channel. If the row save fails, the backing channel is archived -// to avoid an orphan. +// space row pointing at it, and adds the creator as a member with SchemeAdmin. space.ChannelId +// must be empty — it is set from the created channel. defaultCapabilities nil defaults to the +// contribute preset; viewAccess nil defaults to open. If any step after the backing channel's +// creation fails, the backing channel is archived to avoid an orphan, and a newly created custom +// scheme (a non-preset defaultCapabilities) is best-effort retired. // // The channel create and the row save are separate systems with no shared transaction: a crash // between them leaves a real channel with no space row and no persisted marker to key a retry // off, so that window is cleaned up only by the best-effort compensating archive below (or an // operator, if that also fails). -func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, *mmmodel.AppError) { +func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabilities *[]string, viewAccess *string) (*model.Space, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.nil_input.app_error", nil, "", http.StatusBadRequest) } @@ -169,11 +231,40 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, if !active { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.not_team_member.app_error", nil, "", http.StatusForbidden) } + // Team membership alone does not authorize creating a space in it: the caller must also hold + // create_space on the team (or be sysadmin). Unlike the read/manage/delete gates, no space + // exists yet here, so there is nothing to existence-hide behind — a plain 403 is correct. + if !s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) && + !s.client.User.HasPermissionToTeam(userID, space.TeamId, mmmodel.PermissionCreateSpace) { + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.forbidden.app_error", nil, "", http.StatusForbidden) + } // Sanitize before it's used as the channel Header below — Space.PreSave sanitizes it again on // the store.CreateSpace path, but that happens after the channel is already created. space.Description = mmmodel.SanitizeUnicode(space.Description) space.CreatorId = userID + va := model.ViewAccessOpen + if viewAccess != nil { + va = *viewAccess + } + if va != model.ViewAccessOpen && va != model.ViewAccessPrivate { + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.invalid_view_access.app_error", nil, "", http.StatusBadRequest) + } + space.ViewAccess = va + + caps, _ := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + if defaultCapabilities != nil { + caps = *defaultCapabilities + } + if capErr := model.ValidateDefaultCapabilities(caps); capErr != nil { + return nil, capErr + } + + schemeID, createdCustom, schemeErr := s.resolveSpaceScheme(caps) + if schemeErr != nil { + return nil, storeAppError("CreateSpace", schemeErr) + } + s.log.Debug("Creating space", "team_id", space.TeamId, "user_id", userID) backingChannel := &mmmodel.Channel{ @@ -181,6 +272,7 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, Type: mmmodel.ChannelTypeSpace, Name: "space-" + mmmodel.NewId()[:20], CreatorId: userID, + SchemeId: &schemeID, } applySpaceFieldsToChannel(backingChannel, space) if err := s.client.Channel.Create(backingChannel); err != nil { @@ -188,7 +280,10 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, // backingChannel before its post-create bookkeeping, so an error alongside a populated // Id means the channel row already exists and must be archived, not leaked. if backingChannel.Id != "" { - s.archiveOrphanChannel(backingChannel.Id, "channel create failed after creation", err) + s.abandonBackingChannel(backingChannel.Id, "channel create failed after creation", err, schemeID, createdCustom) + } else { + // No channel row exists, so nothing references the scheme. + s.cleanupCustomScheme(schemeID, createdCustom, "") } return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.backing_channel_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -197,15 +292,29 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, // A space whose creator is not a member of its backing channel is a dead-end once per-space // membership gating lands (unreachable to everyone, creator included), so fail the create // and archive the orphan channel rather than continuing. - s.archiveOrphanChannel(backingChannel.Id, "creator member-add failed", addErr) + s.abandonBackingChannel(backingChannel.Id, "creator member-add failed", addErr, schemeID, createdCustom) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.add_member_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(addErr) } + // The creator is added as SchemeAdmin — both the scheme's resolved generated role names, never + // literals: on a scheme-backed channel core rejects the literal channel_user/channel_admin + // tokens. The base user-role token is required, not optional (core resets all scheme flags and + // rejects a string that leaves SchemeUser unset). + schemeRoles, rolesErr := s.store.GetSchemeRolesForChannel(backingChannel.Id) + if rolesErr != nil { + s.abandonBackingChannel(backingChannel.Id, "scheme role lookup failed", rolesErr, schemeID, createdCustom) + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(rolesErr) + } + if _, roleErr := s.client.Channel.UpdateChannelMemberRoles(backingChannel.Id, userID, schemeRoles.UserRoleName+" "+schemeRoles.AdminRoleName); roleErr != nil { + s.abandonBackingChannel(backingChannel.Id, "creator admin role assignment failed", roleErr, schemeID, createdCustom) + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.admin_role_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(roleErr) + } + space.ChannelId = backingChannel.Id saved, err := s.store.CreateSpace(space) if err != nil { - s.archiveOrphanChannel(backingChannel.Id, "row save failed", err) + s.abandonBackingChannel(backingChannel.Id, "row save failed", err, schemeID, createdCustom) return nil, storeAppError("CreateSpace", err) } @@ -238,191 +347,171 @@ func (s *Service) GetSpaceWithDeleted(spaceID string) (*model.Space, *mmmodel.Ap return space, nil } -// CheckSpaceMembership verifies that userID is a member of the space's backing channel — and, -// when the space belongs to a team, still an active member of that team — and returns the -// fetched space on success so callers can avoid a redundant read. The team check exists because -// leaving a team does not remove a user from the team's space channels (core's team-leave sweep -// covers only regular message channels), so channel membership alone would let a former team -// member keep using known space and page IDs. When includeDeleted is true the space row is -// fetched regardless of its DeleteAt state, which is required for operations that run against a -// soft-deleted space (e.g. restore). Non-members, former team members, and non-existent spaces -// all yield the same 403 to prevent callers from probing space existence via the error code. A -// missing or malformed userID is rejected, never treated as a trusted caller; callers that -// legitimately act without a user must read the space directly instead. -func (s *Service) CheckSpaceMembership(spaceID, userID string, includeDeleted bool) (*model.Space, *mmmodel.AppError) { - if appErr := s.requireClient("CheckSpaceMembership", "space_id", spaceID, "user_id", userID); appErr != nil { - return nil, appErr - } - if !mmmodel.IsValidId(userID) { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.invalid_user_id.app_error", nil, "", http.StatusBadRequest) - } - var space *model.Space - var getErr *mmmodel.AppError - if includeDeleted { - space, getErr = s.GetSpaceWithDeleted(spaceID) - } else { - space, getErr = s.GetSpace(spaceID) - } - if getErr != nil { - if getErr.StatusCode == http.StatusNotFound { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(getErr) - } - return nil, getErr +// spaceDefaultCapabilities returns space's current default capability set in wire form +// (read_page-free): preset recognition by the backing channel's scheme name, or — for a +// space-private custom scheme — the generated user role's stored permission set projected onto +// the capability vocabulary. +func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) { + roles, err := s.store.GetSchemeRolesForChannel(space.ChannelId) + if err != nil { + return nil, err } - if space.TeamId != "" { - active, teamErr := s.isActiveTeamMember(space.TeamId, userID) - if teamErr != nil { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr) - } - if !active { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden) - } + if caps, ok := model.DefaultCapabilitiesForSchemeName(roles.SchemeName); ok { + return caps, nil } - if _, err := s.client.Channel.GetMember(space.ChannelId, userID); err != nil { - if errors.Is(err, pluginapi.ErrNotFound) { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(err) - } - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + perms, err := s.store.GetRolePermissionsByName(roles.UserRoleName) + if err != nil { + return nil, err } - return space, nil + return model.DefaultCapabilitiesFromPermissions(perms), nil } -// ListSpaceMembers returns one page of space's members plus whether more members exist beyond -// it. page/perPage are normalized like every other paginated method (page and perPage both -// clamped). The pluginapi member listing is page-indexed rather than offset-based, so when the -// requested page comes back full a one-row probe at the next page's first slot decides has-more. -// space is the caller's already-fetched record (from its membership gate), so no re-read here. -func (s *Service) ListSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { - if space == nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) +// BuildSpaceWithAccess resolves the GET /spaces/{id} response wrapper: the space's default +// capability set plus the caller's own truthful, current effective capabilities — never a +// hypothetical post-join grant. A denied read yields the shared existence-hiding 403. +func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*model.SpaceWithAccess, *mmmodel.AppError) { + if appErr := s.requireClient("BuildSpaceWithAccess", "space_id", space.Id, "user_id", userID); appErr != nil { + return nil, appErr } - if appErr := s.requireClient("ListSpaceMembers", "space_id", space.Id); appErr != nil { - return nil, false, appErr + // The read gate resolves first: a denied caller must get the existence-hiding 403 rather than + // whatever the default-capability lookup below would surface for a space it cannot see. + resolution, resErr := s.ResolveSpaceRead("BuildSpaceWithAccess", space, userID) + if resErr != nil { + return nil, resErr } - page = ClampPage(page) - perPage = ClampPerPage(perPage) - channelMembers, err := s.client.Channel.ListMembers(space.ChannelId, page, perPage) + if resolution == ReadDenied { + return nil, existenceHidingForbidden("BuildSpaceWithAccess") + } + defaultCaps, err := s.spaceDefaultCapabilities(space) if err != nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - members := make([]*model.SpaceMember, 0, len(channelMembers)) - for _, cm := range channelMembers { - members = append(members, &model.SpaceMember{UserId: cm.UserId}) - } - hasMore := false - if len(channelMembers) == perPage { - // A page of size 1 holds exactly one element, so its page index equals that element's - // offset: requesting page (page+1)*perPage at size 1 fetches precisely the first member - // beyond the current window. - probe, probeErr := s.client.Channel.ListMembers(space.ChannelId, (page+1)*perPage, 1) - if probeErr != nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) + return nil, storeAppError("BuildSpaceWithAccess", err) + } + + var caps []string + switch resolution { + case ReadViaSysadmin: + caps = model.AdminEffectiveCapabilities() + case ReadViaMember: + member, memErr := s.client.Channel.GetMember(space.ChannelId, userID) + if memErr != nil { + return nil, mmmodel.NewAppError("BuildSpaceWithAccess", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) } - hasMore = len(probe) > 0 + caps = model.CapabilitiesFromMember(member.ExplicitRoles, member.SchemeAdmin, member.SchemeGuest, defaultCaps).Effective + case ReadViaOpenFallthrough: + caps = []string{model.CapabilityReadPage} + default: + return nil, existenceHidingForbidden("BuildSpaceWithAccess") } - return members, hasMore, nil + + wrapper := &model.SpaceWithAccess{Space: *space, DefaultCapabilities: defaultCaps, Capabilities: caps} + wrapper.EnsureCapabilities() + return wrapper, nil } -// AddSpaceMember adds a user to space's backing channel. Any current space member may manage -// members (flat model; no per-space admin role yet). space is the caller's already-fetched -// record (from its membership gate), so no re-read here. -func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.SpaceMember, *mmmodel.AppError) { +// SetSpaceDefaultCapabilities changes space's default capability set: a set matching a seeded +// preset repoints the backing channel at the shared preset scheme; any other set creates a new +// immutable space-private custom scheme and repoints, retiring the previous custom scheme once +// unreferenced. The repoint goes through pluginapi Channel.Update (not a store-direct write) so +// core's member-cache invalidation runs and the switch is authorization-visible immediately. +func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, actingUserID string) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + return nil, mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } - if !mmmodel.IsValidId(userID) { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + if appErr := model.ValidateDefaultCapabilities(caps); appErr != nil { + return nil, appErr } - if appErr := s.requireClient("AddSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { + if appErr := s.requireClient("SetSpaceDefaultCapabilities", "space_id", space.Id); appErr != nil { return nil, appErr } - // Reject a target who is not an active member of the space's team before touching the - // backing channel. Core's channel-member add enforces the same integrity check but surfaces - // it as an opaque failure; checking here keeps the status code honest and guarantees every - // space member can pass the team half of the access gate — which the last-member guard in - // RemoveSpaceMember relies on when deciding who can still reach the space. - if space.TeamId != "" { - active, memberErr := s.isActiveTeamMember(space.TeamId, userID) - if memberErr != nil { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) + + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + channel, chanErr := s.client.Channel.GetChannelOfType(space.ChannelId, mmmodel.ChannelTypeSpace) + if chanErr != nil { + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(chanErr) } - if !active { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) + if channel == nil || channel.SchemeId == nil { + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.channel_scheme_missing.app_error", nil, "", http.StatusInternalServerError) } - } - member, err := s.client.Channel.AddMember(space.ChannelId, userID) - if err != nil { - // A missing target user is the caller's mistake, not a server fault. - if errors.Is(err, pluginapi.ErrNotFound) { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + currentSchemeID := *channel.SchemeId + // Read the superseded scheme's name before the repoint below rewrites which scheme the + // channel points at. A failure here only costs the retirement step, so it is carried as a + // value and handled after the repoint rather than failing the whole operation. + currentRoles, currentRolesErr := s.store.GetSchemeRolesForChannel(space.ChannelId) + + // Compare against the live default before resolving a scheme. resolveSpaceScheme returns a + // pre-existing id only for the three presets — a non-preset set always creates a fresh + // scheme — so without this an unchanged custom set would create, repoint, and retire on + // every save, and the id comparison below could never catch it. + if currentRolesErr == nil { + if liveCaps, capsErr := s.spaceDefaultCapabilities(space); capsErr == nil && + slices.Equal(liveCaps, model.NormalizeCapabilitySet(caps)) { + return nil + } } - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": member.UserId}, space.ChannelId) - return &model.SpaceMember{UserId: member.UserId}, nil -} -// RemoveSpaceMember removes a user from space's backing channel. The last member who can still -// reach the space cannot be removed: membership is the only gate on every space and page route, -// so a space with no reachable member — and every page in it — would be permanently unreachable -// through the plugin API (there is no admin bypass, and adding a member back requires the caller -// to already be one). Reachable means passing the full access gate, so for a team space a member -// who has since left the team does not count — see hasOtherAuthorizedMember. space is the -// caller's already-fetched record (from its membership gate), so no re-read here. -func (s *Service) RemoveSpaceMember(space *model.Space, userID string) *mmmodel.AppError { - if space == nil { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) - } - if !mmmodel.IsValidId(userID) { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) - } - if appErr := s.requireClient("RemoveSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { - return appErr - } - // The member-list read and the removal below are separate calls, so on their own two - // concurrent removals of a two-member space's remaining members could each pass the - // last-member guard and leave the space memberless — unreachable through the plugin API. - // The space-scoped advisory lock serializes the guard and the removal as one unit. - lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { - // Removing a non-member alongside a sole reachable member falls through the guard: the - // DeleteMember call below reports that failure. - hasOther, guardErr := s.hasOtherAuthorizedMember(space, userID) - if guardErr != nil { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + targetSchemeID, createdCustom, schemeErr := s.resolveSpaceScheme(caps) + if schemeErr != nil { + return storeAppError("SetSpaceDefaultCapabilities", schemeErr) } - if !hasOther { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.last_member.app_error", nil, "", http.StatusConflict) + if targetSchemeID == currentSchemeID { + // No-op: requested set already matches the live default. + return nil } - if err := s.client.Channel.DeleteMember(space.ChannelId, userID); err != nil { - // A target user who isn't a member (or doesn't exist) is the caller's mistake, - // not a server fault. - if errors.Is(err, pluginapi.ErrNotFound) { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + + channel.SchemeId = &targetSchemeID + if updErr := s.client.Channel.Update(channel); updErr != nil { + s.cleanupCustomScheme(targetSchemeID, createdCustom, "") + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.repoint_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) + } + + // Retire the superseded scheme, but only inside this lock (a concurrent repoint between an + // outside-the-lock read and the retire call could otherwise delete a scheme a racing + // SetSpaceDefaultCapabilities just repointed away from) and only when it was a + // space-private custom one — a routine preset-to-preset switch must neither call the + // retire path nor log a spurious failure Warn. + if currentRolesErr != nil { + s.log.Warn("failed to determine whether the superseded space scheme is a preset; skipping custom-scheme retirement", "scheme_id", currentSchemeID, "err", currentRolesErr) + } else if _, isPreset := model.DefaultCapabilitiesForSchemeName(currentRoles.SchemeName); !isPreset { + if delErr := s.store.DeleteSpaceCustomSchemeIfUnreferenced(currentSchemeID, ""); delErr != nil { + s.log.Warn("failed to retire unreferenced space custom scheme after a default-capabilities change", "scheme_id", currentSchemeID, "err", delErr) } - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil }) if lockErr != nil { var appErr *mmmodel.AppError if errors.As(lockErr, &appErr) { - return appErr + return nil, appErr } - // The store's own errors — notably the retryable ErrConflict a lock-acquisition timeout - // yields — keep their conventional status codes rather than collapsing to a 500. - return storeAppError("RemoveSpaceMember", lockErr) - } - payload := map[string]any{"space_id": space.Id, "user_id": userID} - s.publishToChannels(wsEventSpaceMemberRemoved, payload, space.ChannelId) - // The removed user has already left the backing channel, so the channel-scoped broadcast - // above never reaches them; send the event to their own connections directly. - s.publishToUser(wsEventSpaceMemberRemoved, payload, userID) - return nil + return nil, storeAppError("SetSpaceDefaultCapabilities", lockErr) + } + + fresh, getErr := s.GetSpace(space.Id) + if getErr != nil { + // The scheme repoint already committed, so re-reporting this as a failure would misreport + // success as an error; project the response from the requested set and the pre-update + // space instead, still firing the WS event. + s.log.Warn("SetSpaceDefaultCapabilities: post-commit re-read failed; responding from the requested set", "space_id", space.Id, "err", getErr) + s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": space.Id}, space.ChannelId) + wrapper, buildErr := s.BuildSpaceWithAccess(space, actingUserID) + if buildErr != nil { + return nil, buildErr + } + wrapper.DefaultCapabilities = model.NormalizeCapabilitySet(caps) + return wrapper, nil + } + s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": fresh.Id}, fresh.ChannelId) + return s.BuildSpaceWithAccess(fresh, actingUserID) } -// GetSpacesForTeam returns one page of a team's live spaces, plus whether more exist beyond -// it. userID is verified to be a team member and the result is filtered to spaces whose -// backing channel the caller belongs to, matching the membership gate on single-space reads. +// ListSpaceMembers, AddSpaceMember, SetSpaceMemberCapabilities, and RemoveSpaceMember live in +// space_members.go alongside the escalation and last-admin guards. + +// GetSpacesForTeam returns one page of a team's live spaces, plus whether more exist beyond it. +// userID must be an active team member holding team read_space (the list-entry gate; every +// team_user holds it by default). The result is the union of spaces the caller is a +// backing-channel member of and open spaces the caller can reach via the same team +// read_public_channel/compliance-mode conjunct as single-space read. func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([]*model.Space, bool, *mmmodel.AppError) { if !mmmodel.IsValidId(teamID) { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.invalid_team_id.app_error", nil, "", http.StatusBadRequest) @@ -441,7 +530,12 @@ func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([] if !active { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.not_team_member.app_error", nil, "", http.StatusForbidden) } - spaces, err := s.store.GetSpacesForTeam(teamID, userID, offset, limit) + if !s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) && + !s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadSpace) { + return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.forbidden.app_error", nil, "", http.StatusForbidden) + } + callerHasOpenFallthrough := active && s.openTeamFallthrough(userID, teamID) + spaces, err := s.store.GetSpacesForTeam(teamID, userID, callerHasOpenFallthrough, offset, limit) if err != nil { return nil, false, storeAppError("GetSpacesForTeam", err) } @@ -464,6 +558,9 @@ func normalizeAndValidateSpacePatch(where string, patch *model.SpacePatch) *mmmo } patch.Title = &normalized } + if patch.ViewAccess != nil && *patch.ViewAccess != model.ViewAccessOpen && *patch.ViewAccess != model.ViewAccessPrivate { + return mmmodel.NewAppError(where, "app.space.update.invalid_view_access.app_error", nil, "", http.StatusBadRequest) + } description, icon := "", "" if patch.Description != nil { description = *patch.Description @@ -482,7 +579,14 @@ func normalizeAndValidateSpacePatch(where string, patch *model.SpacePatch) *mmmo // so a forced update overwrites only the fields the patch supplies — concurrent changes to // other fields survive. space is the caller's already-fetched record (from its // membership gate); only its Id is used here. -func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expectedUpdateAt *int64, force bool) (*model.Space, *mmmodel.AppError) { +// +// A patch that changes ViewAccess requires RequireSpaceAdminOrSysadmin against the live row and is +// rejected when force=true. actingUserID is used only for that escalation check. +// +// Any patch on a space with a backing channel runs under the space's membership advisory lock (the +// same lock auto-join and the last-admin invariant use), since it also drives the channel-metadata +// sync below and both writers must serialize against the same lock. +func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expectedUpdateAt *int64, force bool, actingUserID string) (*model.Space, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("UpdateSpace", "app.space.update.invalid_id.app_error", nil, "", http.StatusBadRequest) } @@ -492,24 +596,85 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec if appErr := normalizeAndValidateSpacePatch("UpdateSpace", patch); appErr != nil { return nil, appErr } + if patch.ViewAccess != nil && force { + return nil, mmmodel.NewAppError("UpdateSpace", "app.space.update.view_access_force.app_error", nil, "", http.StatusBadRequest) + } s.log.Debug("Updating space", "space_id", space.Id) - updated, err := s.store.UpdateSpace(space.Id, patch, mmmodel.SafeDereference(expectedUpdateAt), force) - if err != nil { - return nil, storeAppError("UpdateSpace", err) - } - if updated.ChannelId != "" && s.client != nil { - if chanErr := s.syncSpaceChannelMetadata(updated.Id); chanErr != nil { - // Deliberately not returned: the space row (the source of truth) committed, so failing - // the request would misreport a successful update, and retrying it would 409 on the - // now-stale baseline. The next successful UpdateSpace re-syncs the channel. Logged at - // Error so the resulting name/header divergence is visible to operators. - s.log.Error("UpdateSpace: failed to sync backing channel metadata; display name/header stale until the next update", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", chanErr) + apply := func() (*model.Space, *mmmodel.AppError) { + var live *model.Space + if patch.ViewAccess != nil { + gotLive, liveErr := s.store.GetSpace(space.Id, false) + if liveErr != nil { + return nil, storeAppError("UpdateSpace", liveErr) + } + live = gotLive + if *patch.ViewAccess != live.ViewAccess { + if appErr := s.RequireSpaceAdminOrSysadmin("UpdateSpace", live, actingUserID); appErr != nil { + return nil, appErr + } + } + } + updated, err := s.store.UpdateSpace(space.Id, patch, mmmodel.SafeDereference(expectedUpdateAt), force) + if err != nil { + return nil, storeAppError("UpdateSpace", err) + } + // A private->private no-op (the patch re-asserts the current value) must not attach + // member_count: only a genuine open->private transition sheds no members but exposes an + // admin to a count they should act on. Compare against the live row's prior value, not + // just the patch value. + flippedToPrivate := patch.ViewAccess != nil && *patch.ViewAccess == model.ViewAccessPrivate && + live != nil && live.ViewAccess == model.ViewAccessOpen + if updated.ChannelId != "" && s.client != nil { + if chanErr := s.syncSpaceChannelMetadata(updated.Id); chanErr != nil { + // Deliberately not returned: the space row (the source of truth) committed, so + // failing the request would misreport a successful update, and retrying it would + // 409 on the now-stale baseline. The next successful UpdateSpace re-syncs the + // channel. Logged at Error so the resulting name/header divergence is visible to + // operators. + s.log.Error("UpdateSpace: failed to sync backing channel metadata; display name/header stale until the next update", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", chanErr) + } + } + payload := map[string]any{"space_id": updated.Id} + if flippedToPrivate { + // Privatizing does not shed members: every existing member (including anyone + // auto-joined earlier) stays. Surface the current count so the admin is prompted to + // prune via RemoveSpaceMember. + stats, statsErr := s.client.Channel.GetChannelStats(updated.ChannelId) + if statsErr != nil { + // The update itself committed, so this only affects the prompt: the event ships + // without member_count rather than failing a successful write. + s.log.Warn("UpdateSpace: member-count lookup failed; the view_access event omits member_count", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", statsErr) + } else { + payload["member_count"] = stats.MemberCount + s.log.Debug("space view_access flipped to private", "space_id", updated.Id, "member_count", stats.MemberCount) + } + } + s.publishToChannels(wsEventSpaceUpdated, payload, updated.ChannelId) + return updated, nil + } + + if space.ChannelId == "" { + return apply() + } + var result *model.Space + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + r, appErr := apply() + if appErr != nil { + return appErr + } + result = r + return nil + }) + if lockErr != nil { + var appErr *mmmodel.AppError + if errors.As(lockErr, &appErr) { + return nil, appErr } + return nil, storeAppError("UpdateSpace", lockErr) } - s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": updated.Id}, updated.ChannelId) - return updated, nil + return result, nil } // syncSpaceChannelMetadata projects the space's current Title and Description onto its backing diff --git a/server/app/space_members.go b/server/app/space_members.go new file mode 100644 index 0000000..6145e18 --- /dev/null +++ b/server/app/space_members.go @@ -0,0 +1,356 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + "slices" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// ListSpaceMembers returns one page of space's members plus whether more members exist beyond +// it, each projected to their effective/granted capabilities. page/perPage are normalized +// like every other paginated method (page and perPage both clamped). The pluginapi member +// listing is page-indexed rather than offset-based, so when the requested page comes back full a +// one-row probe at the next page's first slot decides has-more. space is the caller's +// already-fetched record (from its manage gate), so no re-read here. +func (s *Service) ListSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { + if space == nil { + return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := s.requireClient("ListSpaceMembers", "space_id", space.Id); appErr != nil { + return nil, false, appErr + } + defaultCaps, err := s.spaceDefaultCapabilities(space) + if err != nil { + return nil, false, storeAppError("ListSpaceMembers", err) + } + page = ClampPage(page) + perPage = ClampPerPage(perPage) + channelMembers, err := s.client.Channel.ListMembers(space.ChannelId, page, perPage) + if err != nil { + return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + members := make([]*model.SpaceMember, 0, len(channelMembers)) + for _, cm := range channelMembers { + members = append(members, projectSpaceMember(cm, defaultCaps)) + } + hasMore := false + if len(channelMembers) == perPage { + // A page of size 1 holds exactly one element, so its page index equals that element's + // offset: requesting page (page+1)*perPage at size 1 fetches precisely the first member + // beyond the current window. + probe, probeErr := s.client.Channel.ListMembers(space.ChannelId, (page+1)*perPage, 1) + if probeErr != nil { + return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) + } + hasMore = len(probe) > 0 + } + return members, hasMore, nil +} + +// projectSpaceMember builds the wire projection of a channel member's capability state. +func projectSpaceMember(cm *mmmodel.ChannelMember, defaultCaps []string) *model.SpaceMember { + mc := model.CapabilitiesFromMember(cm.ExplicitRoles, cm.SchemeAdmin, cm.SchemeGuest, defaultCaps) + member := &model.SpaceMember{ + UserId: cm.UserId, + Capabilities: mc.Effective, + GrantedCapabilities: mc.Granted, + IsAdmin: mc.IsAdmin, + IsGuest: mc.IsGuest, + } + member.EnsureCapabilities() + return member +} + +// AddSpaceMember adds a user to space's backing channel at the space default (SchemeUser, no +// per-member grants). space is the caller's already-fetched record (from its manage gate), so no +// re-read here. +func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.SpaceMember, *mmmodel.AppError) { + if space == nil { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(userID) { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := s.requireClient("AddSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { + return nil, appErr + } + // Reject a target who is not an active member of the space's team before touching the + // backing channel. Core's channel-member add enforces the same integrity check but surfaces + // it as an opaque failure; checking here keeps the status code honest and guarantees every + // space member can pass the team half of the access gate — which the last-authorized-member + // guard in RemoveSpaceMember relies on when deciding who can still reach the space. + if space.TeamId != "" { + active, memberErr := s.isActiveTeamMember(space.TeamId, userID) + if memberErr != nil { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) + } + if !active { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) + } + } + defaultCaps, err := s.spaceDefaultCapabilities(space) + if err != nil { + return nil, storeAppError("AddSpaceMember", err) + } + member, err := s.client.Channel.AddMember(space.ChannelId, userID) + if err != nil { + // A missing target user is the caller's mistake, not a server fault. + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": member.UserId}, space.ChannelId) + return projectSpaceMember(member, defaultCaps), nil +} + +// hasOtherAuthorizedAdmin reports whether space's backing channel still has a SchemeAdmin member +// who is an active team member once excludeUserID is disregarded — the last-admin invariant's +// authorized-admin predicate (mirroring hasOtherAuthorizedMember's team-active discipline and its +// stop-at-the-first-hit walk). excludeUserID, when non-empty, is skipped, so the answer describes +// what would remain after that user is demoted or removed. +func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID string) (bool, error) { + found := false + err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { + if !cm.SchemeAdmin || cm.UserId == excludeUserID { + return false, nil + } + if space.TeamId == "" { + found = true + return true, nil + } + active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) + if activeErr != nil { + return false, activeErr + } + if active { + found = true + return true, nil + } + return false, nil + }) + return found, err +} + +// SetSpaceMemberCapabilities replaces targetUserID's per-member granted capability set. Callers +// must already hold manage-tier authority over the space (see RequireSpaceManage); this method +// additionally enforces the self/admin escalation guard and the last-admin invariant. Guest +// members are rejected: they stay read-only via the scheme's guest role. actingUserID is the +// caller, used only to decide the self-escalation guard. +func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID string, caps []string, actingUserID string) (*model.SpaceMember, *mmmodel.AppError) { + if space == nil { + return nil, mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(targetUserID) { + return nil, mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := model.ValidateGrantedCapabilities(caps); appErr != nil { + return nil, appErr + } + if appErr := s.requireClient("SetSpaceMemberCapabilities", "space_id", space.Id, "user_id", targetUserID); appErr != nil { + return nil, appErr + } + + requestedAdmin := slices.Contains(caps, model.CapabilityAdminSpace) + selfTargeted := targetUserID == actingUserID + + // The scheme-role read, the target's current admin status, the escalation-guard decision, and + // the admin count all read state that a concurrent SetSpaceDefaultCapabilities (repoints the + // channel's scheme) or SetSpaceMemberCapabilities/RemoveSpaceMember (the last-admin invariant) + // call could change: a stale read taken before the lock lets a concurrent default-capabilities + // repoint write a retired scheme's role name, or a concurrent promotion/demotion flip admin + // status, under this operation's feet — so every one of them runs inside the space-keyed + // advisory lock, alongside the mutation itself. + var appErr *mmmodel.AppError + var newRoles string + var newSchemeAdmin bool + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + schemeRoles, rolesErr := s.store.GetSchemeRolesForChannel(space.ChannelId) + if rolesErr != nil { + appErr = storeAppError("SetSpaceMemberCapabilities", rolesErr) + return appErr + } + newRoles, newSchemeAdmin = model.RolesForCapabilities(caps, schemeRoles.UserRoleName) + + target, memErr := s.client.Channel.GetMember(space.ChannelId, targetUserID) + if memErr != nil { + if errors.Is(memErr, pluginapi.ErrNotFound) { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(memErr) + return appErr + } + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) + return appErr + } + if target.SchemeGuest { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.guest_not_assignable.app_error", nil, "", http.StatusBadRequest) + return appErr + } + + adminAffected := target.SchemeAdmin || requestedAdmin + if adminAffected || selfTargeted { + if e := s.RequireSpaceAdminOrSysadmin("SetSpaceMemberCapabilities", space, actingUserID); e != nil { + appErr = e + return e + } + } + + if target.SchemeAdmin && !newSchemeAdmin { + otherAdmin, cntErr := s.hasOtherAuthorizedAdmin(space, targetUserID) + if cntErr != nil { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cntErr) + return appErr + } + if !otherAdmin { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) + return appErr + } + } + + roles := newRoles + if newSchemeAdmin { + roles = roles + " " + schemeRoles.AdminRoleName + } + if _, updErr := s.client.Channel.UpdateChannelMemberRoles(space.ChannelId, targetUserID, roles); updErr != nil { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.update_capabilities_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) + return appErr + } + return nil + }) + if lockErr != nil { + if appErr != nil { + return nil, appErr + } + return nil, storeAppError("SetSpaceMemberCapabilities", lockErr) + } + + defaultCaps, defErr := s.spaceDefaultCapabilities(space) + if defErr != nil { + return nil, storeAppError("SetSpaceMemberCapabilities", defErr) + } + fresh, memErr := s.client.Channel.GetMember(space.ChannelId, targetUserID) + var result *model.SpaceMember + if memErr != nil { + // The role update already committed, so re-reporting this as a failure would misreport + // success as an error; project the response from the requested capability set instead + // (guests are rejected above, so schemeGuest is always false here), still firing the WS + // event. + s.log.Warn("SetSpaceMemberCapabilities: post-commit member re-read failed; responding from the requested set", "space_id", space.Id, "user_id", targetUserID, "err", memErr) + result = projectSpaceMember(&mmmodel.ChannelMember{ + UserId: targetUserID, + ExplicitRoles: newRoles, + SchemeAdmin: newSchemeAdmin, + }, defaultCaps) + } else { + result = projectSpaceMember(fresh, defaultCaps) + } + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + // Delivered both ways deliberately: the channel-scoped broadcast covers observers, and the + // direct publish guarantees the target learns its own capabilities changed even if the + // channel-scoped resolution misses them on a space ("S") channel. + s.publishToUser(wsEventSpaceMemberCapabilitiesUpdated, payload, targetUserID) + s.publishToChannels(wsEventSpaceMemberCapabilitiesUpdated, payload, space.ChannelId) + return result, nil +} + +// RemoveSpaceMember removes a user from space's backing channel. Precedence: target existence +// resolves before the last-member/last-admin guards; an admin-target removal is additionally +// escalation-guarded (RequireSpaceAdminOrSysadmin) and last-admin-guarded, both under the same +// space-scoped advisory lock as SetSpaceMemberCapabilities' admin-revoke path. space is the +// caller's already-fetched record (from its gate), so no re-read here. +func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID string) *mmmodel.AppError { + if space == nil { + return mmmodel.NewAppError("RemoveSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(userID) { + return mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := s.requireClient("RemoveSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { + return appErr + } + + // The target lookup, its admin status, the escalation-guard decision, and the admin count all + // read state a concurrent SetSpaceMemberCapabilities/RemoveSpaceMember call could change + // (the last-admin invariant) — a stale admin-status read taken before the lock lets a + // concurrent promotion flip it under this operation's feet — so every one of them runs inside + // the space-keyed advisory lock, alongside the mutation itself. This applies to self-removal + // too, since the last-admin invariant covers the sole admin's self-leave. + var appErr *mmmodel.AppError + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + target, memErr := s.client.Channel.GetMember(space.ChannelId, userID) + if memErr != nil { + if !errors.Is(memErr, pluginapi.ErrNotFound) { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) + return appErr + } + // Non-member target: existence-hiding on a private space (matches the read resolver's + // convention), a plain 404 on an open space (existence is already public there). That + // split only applies to self-removal — a manage-gated caller removing someone else has + // already proven manage authority over this space, so there is nothing left to + // existence-hide behind; it always gets the plain 404, matching + // SetSpaceMemberCapabilities. + if userID != actingUserID || space.ViewAccess == model.ViewAccessOpen { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(memErr) + } else { + appErr = existenceHidingForbidden("RemoveSpaceMember") + } + return appErr + } + + if target.SchemeAdmin { + if e := s.RequireSpaceAdminOrSysadmin("RemoveSpaceMember", space, actingUserID); e != nil { + appErr = e + return e + } + otherAdmin, cntErr := s.hasOtherAuthorizedAdmin(space, userID) + if cntErr != nil { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cntErr) + return appErr + } + if !otherAdmin { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) + return appErr + } + } + hasOther, guardErr := s.hasOtherAuthorizedMember(space, userID) + if guardErr != nil { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + return appErr + } + if !hasOther { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.last_member.app_error", nil, "", http.StatusConflict) + return appErr + } + if err := s.client.Channel.DeleteMember(space.ChannelId, userID); err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + return appErr + } + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return appErr + } + return nil + }) + if lockErr != nil { + if appErr != nil { + return appErr + } + // The store's own errors — notably the retryable ErrConflict a lock-acquisition timeout + // yields — keep their conventional status codes rather than collapsing to a 500. + return storeAppError("RemoveSpaceMember", lockErr) + } + payload := map[string]any{"space_id": space.Id, "user_id": userID} + s.publishToChannels(wsEventSpaceMemberRemoved, payload, space.ChannelId) + // The removed user has already left the backing channel, so the channel-scoped broadcast + // above never reaches them; send the event to their own connections directly. + s.publishToUser(wsEventSpaceMemberRemoved, payload, userID) + return nil +} diff --git a/server/app/ws_events.go b/server/app/ws_events.go index 3053b08..fd3b5f6 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -11,15 +11,17 @@ import ( // can refresh the affected space list and page tree without a full reload. The platform prepends // "custom__" to each name on the wire, so the names carry no redundant plugin prefix. // -// Every event is scoped to the space's backing channel, matching the visibility boundary the REST -// API enforces: reads are membership-gated (CheckSpaceMembership) and the team space list is -// filtered to the caller's channel memberships, so a broader broadcast would leak space existence -// and activity to users who cannot read the space. For the same reason a cross-space move -// publishes a separate payload to each side — the source channel learns only the source space's -// half (source space and old parent), the target channel only the target's half — rather than one -// payload naming both spaces. A member removal is additionally sent to the -// removed user directly, who has already left the channel when the channel-scoped broadcast fires -// and would otherwise never learn of it. space_deleted is likewise delivered to each member +// Every event is scoped to the space's backing channel. Reads are no longer purely +// membership-gated (an open space also admits non-member reads via the team read_public_channel +// fall-through, and the team space list includes open spaces the caller hasn't joined), but WS +// delivery deliberately stays channel-scoped: a non-member reader of an open space receives no +// live updates until a write auto-joins them. This is narrower than the read set — an accepted +// UX consequence, not a leak, since the same caller can always re-fetch. For the same reason a +// cross-space move publishes a separate payload to each side — the source channel learns only the +// source space's half (source space and old parent), the target channel only the target's half — +// rather than one payload naming both spaces. A member removal is additionally sent to the removed +// user directly, who has already left the channel when the channel-scoped broadcast fires and +// would otherwise never learn of it. space_deleted is likewise delivered to each member // directly, from a snapshot taken before the backing channel is archived — channel-scoped // delivery resolves recipients from live channels only, so a broadcast to the just-archived // channel would reach nobody. A space delete or restore cascades to every live page in @@ -35,12 +37,13 @@ const ( wsEventPageDuplicated = "page_duplicated" wsEventPageMovedToSpace = "page_moved_to_space" - wsEventSpaceCreated = "space_created" - wsEventSpaceUpdated = "space_updated" - wsEventSpaceDeleted = "space_deleted" - wsEventSpaceRestored = "space_restored" - wsEventSpaceMemberAdded = "space_member_added" - wsEventSpaceMemberRemoved = "space_member_removed" + wsEventSpaceCreated = "space_created" + wsEventSpaceUpdated = "space_updated" + wsEventSpaceDeleted = "space_deleted" + wsEventSpaceRestored = "space_restored" + wsEventSpaceMemberAdded = "space_member_added" + wsEventSpaceMemberRemoved = "space_member_removed" + wsEventSpaceMemberCapabilitiesUpdated = "space_member_capabilities_updated" ) // publishToChannels publishes a WebSocket event broadcast to each non-empty, distinct channel ID. From 0a6cc0015f1d3837853092c10bbf7eb835560c07 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:53 +0200 Subject: [PATCH 07/28] Update app tests for space permission gates --- server/app/page_duplicate_test.go | 38 ++-- server/app/page_move_test.go | 22 +-- server/app/page_move_to_space_test.go | 90 +++++---- server/app/page_reorder_test.go | 4 +- server/app/service_test.go | 70 +++---- server/app/space_test.go | 270 ++++++++++---------------- server/app/ws_events_test.go | 33 ++-- 7 files changed, 230 insertions(+), 297 deletions(-) diff --git a/server/app/page_duplicate_test.go b/server/app/page_duplicate_test.go index c7a8f5f..0bce65f 100644 --- a/server/app/page_duplicate_test.go +++ b/server/app/page_duplicate_test.go @@ -26,7 +26,7 @@ import ( func TestServiceDuplicatePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -54,7 +54,7 @@ func TestServiceDuplicatePage(t *testing.T) { func TestServiceDuplicatePage_CopiesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source, appErr := h.svc.CreatePage(space.Id, "", "Searchable", "body text", "search text", userID) @@ -73,7 +73,7 @@ func TestServiceDuplicatePage_CopiesSearchText(t *testing.T) { func TestServiceDuplicatePage_CopiesProps(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source, appErr := h.svc.CreatePage(space.Id, "", "Has Props", "body text", "", userID) @@ -104,7 +104,7 @@ func TestServiceDuplicatePage_CopiesProps(t *testing.T) { func TestServiceDuplicatePage_TruncatesLongTitle(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() longTitle := strings.Repeat("x", model.PageTitleMaxRunes) @@ -146,7 +146,7 @@ func TestServiceDuplicatePage_NilSpace(t *testing.T) { func TestServiceDuplicatePage_InvalidUserID(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) source := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") _, appErr := h.svc.DuplicatePage(source.Id, space, "not-an-id", false, nil, nil) @@ -161,7 +161,7 @@ func TestServiceDuplicatePage_InvalidUserID(t *testing.T) { func TestServiceDuplicatePage_IncludeChildren(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -198,7 +198,7 @@ func TestServiceDuplicatePage_IncludeChildren(t *testing.T) { func TestServiceDuplicatePage_MaxDepthExceeded(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // A chain of MaxPageDepth pages: the deepest has MaxPageDepth-1 ancestors, so a single page @@ -223,7 +223,7 @@ func TestServiceDuplicatePage_MaxDepthExceeded(t *testing.T) { func TestServiceDuplicatePage_TargetParentNotFound(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -241,7 +241,7 @@ func TestServiceDuplicatePage_TargetParentNotFound(t *testing.T) { func TestServiceDuplicatePage_TargetParentIsSourceItself(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -260,10 +260,10 @@ func TestServiceDuplicatePage_TargetParentInWrongSpace(t *testing.T) { userID := mmmodel.NewId() teamID := mmmodel.NewId() - sourceSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + sourceSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") - targetSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + targetSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) // parentInSource lives in sourceSpace, not targetSpace — an invalid destination parent for a // copy landing in targetSpace. parentInSource := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") @@ -281,10 +281,10 @@ func TestServiceDuplicatePage_TargetSpaceAndParent(t *testing.T) { userID := mmmodel.NewId() teamID := mmmodel.NewId() - sourceSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + sourceSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") - targetSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + targetSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) targetParent := mustCreatePage(t, h.store, targetSpace.Id, targetSpace.ChannelId, userID, "") dup, appErr := h.svc.DuplicatePage(source.Id, sourceSpace, userID, false, targetSpace, &targetParent.Id) @@ -302,11 +302,11 @@ func TestServiceDuplicatePage_CrossSpaceDefaultsToRoot(t *testing.T) { userID := mmmodel.NewId() teamID := mmmodel.NewId() - sourceSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + sourceSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) sourceParent := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, sourceParent.Id) - targetSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + targetSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) dup, appErr := h.svc.DuplicatePage(source.Id, sourceSpace, userID, false, targetSpace, nil) require.Nil(t, appErr) @@ -320,10 +320,10 @@ func TestServiceDuplicatePage_CrossTeamRejected(t *testing.T) { h := openTestService(t) userID := mmmodel.NewId() - sourceSpace := mustCreateSpace(t, h.store, mmmodel.NewId()) + sourceSpace := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") - targetSpace := mustCreateSpace(t, h.store, mmmodel.NewId()) + targetSpace := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.DuplicatePage(source.Id, sourceSpace, userID, false, targetSpace, nil) require.NotNil(t, appErr) @@ -336,7 +336,7 @@ func TestServiceDuplicatePage_CrossTeamRejected(t *testing.T) { func TestServiceDuplicatePage_IncludeChildren_MaxDepthExceeded(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // A chain of MaxPageDepth-1 pages: the deepest has MaxPageDepth-2 ancestors, so @@ -364,7 +364,7 @@ func TestServiceDuplicatePage_IncludeChildren_MaxDepthExceeded(t *testing.T) { func TestServiceDuplicatePage_InvalidParentID(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/page_move_test.go b/server/app/page_move_test.go index 1151402..6d3332a 100644 --- a/server/app/page_move_test.go +++ b/server/app/page_move_test.go @@ -35,7 +35,7 @@ func buildPageChain(t *testing.T, s *store.Store, spaceID, channelID, userID str func TestServiceMovePage_Reparent(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -55,7 +55,7 @@ func TestServiceMovePage_Reparent(t *testing.T) { func TestServiceMovePage_SameParentNoOp(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -73,7 +73,7 @@ func TestServiceMovePage_SameParentNoOp(t *testing.T) { func TestServiceMovePage_ToRootLevel(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -122,11 +122,11 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { userID := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := mustCreateSpace(t, h.store, channelA) + spaceA := mustCreateSpace(t, h.store, h.db, channelA) page := mustCreatePage(t, h.store, spaceA.Id, channelA, userID, "") channelB := mmmodel.NewId() - spaceB := mustCreateSpace(t, h.store, channelB) + spaceB := mustCreateSpace(t, h.store, h.db, channelB) parentInB := mustCreatePage(t, h.store, spaceB.Id, channelB, userID, "") newParent := parentInB.Id @@ -139,7 +139,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("non-existent parent", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") ghost := mmmodel.NewId() @@ -152,7 +152,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("self as parent is a circular reference", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") self := page.Id @@ -165,7 +165,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("moving under own descendant is a circular reference", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() root := mustCreatePage(t, h.store, space.Id, channelID, userID, "") child := mustCreatePage(t, h.store, space.Id, channelID, userID, root.Id) @@ -181,7 +181,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("new depth would exceed MaxPageDepth", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // A chain of MaxPageDepth pages: the deepest has MaxPageDepth-1 ancestors, so a leaf @@ -200,7 +200,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("subtree would push past MaxPageDepth", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // Anchor at depth MaxPageDepth-1 (MaxPageDepth-2 ancestors): a moved page lands at @@ -226,7 +226,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { func TestServiceMovePage_StaleBaselineConflicts(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index c4e3a4a..2efaad0 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -4,6 +4,7 @@ package app_test import ( + "database/sql" "testing" "github.com/stretchr/testify/mock" @@ -19,9 +20,14 @@ import ( ) // seedSpaceForTeam creates a space with a caller-chosen team id (mustCreateSpace randomizes it). -func seedSpaceForTeam(t *testing.T, s *store.Store, channelID, teamID string) *model.Space { +// db seeds a matching Channels stand-in row pointed at the contribute preset scheme, mirroring +// production CreateSpace's default so scheme-resolving app methods (spaceDefaultCapabilities, +// GetSchemeRolesForChannel) work against the space's channel in tests. +func seedSpaceForTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() - return testutil.MustCreateSpace(t, s, channelID, teamID) + space := testutil.MustCreateSpace(t, s, channelID, teamID) + testutil.MustSeedChannelScheme(t, db, channelID, mmmodel.SchemeNameSpaceContribute) + return space } func pageIDs(pages []*model.PageSummary) map[string]bool { @@ -44,14 +50,14 @@ func TestServiceMovePageToSpace(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) root := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") child := mustCreatePage(t, h.store, spaceA.Id, chA, user, root.Id) - moved, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(root.UpdateAt), false, mmmodel.NewId()) + moved, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(root.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, spaceB.Id, moved.SpaceId) require.Equal(t, chB, moved.ChannelId) @@ -82,15 +88,15 @@ func TestServiceMovePageToSpace_ReturnsMovedPage(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) target := mustCreatePage(t, h.store, spaceB.Id, chB, user, "") page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") parentID := target.Id - moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(page.UpdateAt), false, mmmodel.NewId()) + moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(page.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, spaceB.Id, moved.SpaceId) require.Equal(t, chB, moved.ChannelId) @@ -110,12 +116,12 @@ func TestServiceMovePageToSpace_RejectsCrossTeam(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, mmmodel.NewId()) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, mmmodel.NewId()) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, mmmodel.NewId()) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, mmmodel.NewId()) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.cross_team.app_error", appErr.Id) @@ -127,21 +133,21 @@ func TestServiceMovePageToSpace_InvalidIDs(t *testing.T) { someSpace := &model.Space{Id: mmmodel.NewId()} t.Run("invalid pageID", func(t *testing.T) { - _, appErr := h.svc.MovePageToSpace("not-an-id", someSpace, someSpace, nil, new(int64(0)), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace("not-an-id", someSpace, someSpace, nil, new(int64(0)), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.invalid_id.app_error", appErr.Id) }) t.Run("nil source space", func(t *testing.T) { - _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), nil, someSpace, nil, new(int64(0)), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), nil, someSpace, nil, new(int64(0)), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.invalid_source_space.app_error", appErr.Id) }) t.Run("nil target space", func(t *testing.T) { - _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), someSpace, nil, nil, new(int64(0)), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), someSpace, nil, nil, new(int64(0)), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.invalid_target_space.app_error", appErr.Id) @@ -156,15 +162,15 @@ func TestServiceMovePageToSpace_RejectsParentInWrongSpace(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") parentInA := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") parentID := parentInA.Id - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.invalid_parent.app_error", appErr.Id) @@ -179,14 +185,14 @@ func TestServiceMovePageToSpace_RejectsMissingParent(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") ghost := mmmodel.NewId() - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &ghost, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &ghost, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.invalid_parent.app_error", appErr.Id) @@ -200,9 +206,9 @@ func TestServiceMovePageToSpace_RejectsDepthExceeded(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) // Build a chain in spaceB down to MaxPageDepth; a child under the deepest node would breach it. parentID := "" @@ -212,7 +218,7 @@ func TestServiceMovePageToSpace_RejectsDepthExceeded(t *testing.T) { } page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.max_depth_exceeded.app_error", appErr.Id) @@ -226,9 +232,9 @@ func TestServiceMovePageToSpace_RewritesSnapshots(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) root := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") @@ -239,7 +245,7 @@ func TestServiceMovePageToSpace_RewritesSnapshots(t *testing.T) { snap.Id, root.Id, mmmodel.GetMillis()) require.NoError(t, rawErr) - _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId(), "") require.Nil(t, appErr) var spaceID, channelID string @@ -256,13 +262,13 @@ func TestServiceMovePageToSpace_RejectsCycle(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) root := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") child := mustCreatePage(t, h.store, spaceA.Id, chA, user, root.Id) childID := child.Id - _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceA, &childID, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceA, &childID, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.circular_reference.app_error", appErr.Id) @@ -277,19 +283,19 @@ func TestServiceMovePageToSpace_StaleBaselineConflicts(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") // A cross-space move falls through to the store CAS (the no-op short-circuit only applies when // the page is already in the target space under the requested parent). - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode) - moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), true, mmmodel.NewId()) + moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), true, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, spaceB.Id, moved.SpaceId) } @@ -304,22 +310,22 @@ func TestServiceMovePageToSpace_NoOpEnforcesBaseline(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) // A freshly created root page in spaceA: targeting spaceA with parentPageID nil is the genuine // no-op (already in the target space, already at the root). page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode) - same, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt), false, mmmodel.NewId()) + same, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, page.Id, same.Id) require.Equal(t, spaceA.Id, same.SpaceId) - _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), true, mmmodel.NewId()) + _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), true, mmmodel.NewId(), "") require.Nil(t, appErr) } @@ -333,13 +339,13 @@ func TestServiceMovePageToSpace_SameSpaceReparent(t *testing.T) { teamID := mmmodel.NewId() user := mmmodel.NewId() ch := mmmodel.NewId() - space := seedSpaceForTeam(t, h.store, ch, teamID) + space := seedSpaceForTeam(t, h.store, h.db, ch, teamID) newParent := mustCreatePage(t, h.store, space.Id, ch, user, "") page := mustCreatePage(t, h.store, space.Id, ch, user, "") parentID := newParent.Id - moved, appErr := h.svc.MovePageToSpace(page.Id, space, space, &parentID, new(page.UpdateAt), false, user) + moved, appErr := h.svc.MovePageToSpace(page.Id, space, space, &parentID, new(page.UpdateAt), false, user, "") require.Nil(t, appErr) require.Equal(t, newParent.Id, moved.ParentId) require.Equal(t, space.Id, moved.SpaceId) @@ -366,20 +372,20 @@ func TestServiceMovePageToSpace_NoOpRejectsStaleSource(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") // Concurrently relocate the page to spaceB, out from under the caller's stale spaceA route. - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) // The stale request still names spaceA as the source and spaceB as the target; the page is // already in spaceB at the root, which matches the no-op's SpaceId/ParentId check, but not its // sourceSpaceID. - _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId()) + _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 404, appErr.StatusCode) } diff --git a/server/app/page_reorder_test.go b/server/app/page_reorder_test.go index e735e3d..e4ecec5 100644 --- a/server/app/page_reorder_test.go +++ b/server/app/page_reorder_test.go @@ -30,7 +30,7 @@ func childOrder(t *testing.T, h *testHarness, parentID, spaceID string) []string func TestServiceMovePage_PositionalReorder(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -68,7 +68,7 @@ func TestServiceMovePage_PositionalReorder(t *testing.T) { func TestServiceMovePage_ReparentAtIndex(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/service_test.go b/server/app/service_test.go index d88abab..a4701fd 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -60,9 +60,9 @@ func openTestService(t *testing.T) *testHarness { // helpers to create test data directly on the store (bypassing business logic where the // higher-level Create methods need channel/space state that may not exist). -func mustCreateSpace(t *testing.T, s *store.Store, channelID string) *model.Space { +func mustCreateSpace(t *testing.T, s *store.Store, db *sql.DB, channelID string) *model.Space { t.Helper() - return seedSpaceForTeam(t, s, channelID, mmmodel.NewId()) + return seedSpaceForTeam(t, s, db, channelID, mmmodel.NewId()) } func mustCreatePage(t *testing.T, s *store.Store, spaceID, channelID, userID, parentID string) *model.Page { @@ -75,7 +75,7 @@ func TestServiceGetSpace(t *testing.T) { t.Run("found", func(t *testing.T) { channelID := mmmodel.NewId() - saved := mustCreateSpace(t, h.store, channelID) + saved := mustCreateSpace(t, h.store, h.db, channelID) got, err := h.svc.GetSpace(saved.Id) require.Nil(t, err) @@ -97,13 +97,13 @@ func TestServiceCreatePageParentDifferentSpace(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) // Create a real second space (different channel) and seed the rogue parent into it. // Using a real space avoids a foreign-key violation (Pages.SpaceId references Spaces) // and produces a page whose SpaceId is genuinely different from `space.Id`. otherChannelID := mmmodel.NewId() - otherSpace := mustCreateSpace(t, h.store, otherChannelID) + otherSpace := mustCreateSpace(t, h.store, h.db, otherChannelID) rogueParent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") _, err := h.svc.CreatePage(space.Id, rogueParent.Id, "Child", "", "", userID) @@ -133,7 +133,7 @@ func TestServiceDeleteSpace(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - saved := mustCreateSpace(t, h.store, channelID) + saved := mustCreateSpace(t, h.store, h.db, channelID) require.Nil(t, h.svc.DeleteSpace(saved)) @@ -147,7 +147,7 @@ func TestServiceGetPage(t *testing.T) { t.Run("found", func(t *testing.T) { channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") got, err := h.svc.GetPage(created.Id) @@ -166,7 +166,7 @@ func TestServiceUpdatePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -194,7 +194,7 @@ func TestServiceUpdatePageSearchTextWithoutBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -213,7 +213,7 @@ func TestServiceUpdatePageBodyWithoutSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -232,7 +232,7 @@ func TestServiceUpdatePageSearchTextWithBodyCleared(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -253,7 +253,7 @@ func TestServiceUpdatePageClearSearchTextAlone(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -271,7 +271,7 @@ func TestServiceUpdatePageForce(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -312,7 +312,7 @@ func TestServiceUpdatePageInvalidUserID(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") t.Run("UpdatePage rejects malformed userID", func(t *testing.T) { @@ -326,7 +326,7 @@ func TestServiceGetPageChildren(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") child := mustCreatePage(t, h.store, space.Id, channelID, userID, parent.Id) @@ -341,7 +341,7 @@ func TestServiceGetSpacePages(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() for range 3 { mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -357,7 +357,7 @@ func TestServiceGetSpacePages(t *testing.T) { // new space must not leak the old space's pages. require.Nil(t, h.svc.DeleteSpace(space)) - space2 := mustCreateSpace(t, h.store, channelID) + space2 := mustCreateSpace(t, h.store, h.db, channelID) mustCreatePage(t, h.store, space2.Id, channelID, userID, "") pages2, _, err := h.svc.GetSpacePages(space2, 0, 0) @@ -385,7 +385,7 @@ func TestServiceGetTeamSpaces(t *testing.T) { teamID := mmmodel.NewId() userID := mmmodel.NewId() for range 2 { - sp := &model.Space{ChannelId: mmmodel.NewId(), TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Test Space"} + sp := &model.Space{ChannelId: mmmodel.NewId(), TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Test Space", ViewAccess: model.ViewAccessOpen} _, err := h.store.CreateSpace(sp) require.NoError(t, err) testutil.MustAddChannelMember(t, h.db, sp.ChannelId, userID) @@ -411,7 +411,7 @@ func TestServiceCreatePageDerivesChannelFromSpace(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created, err := h.svc.CreatePage(space.Id, "", "My Page", "", "", userID) @@ -424,7 +424,7 @@ func TestServiceCreatePageDerivesChannelFromSpace(t *testing.T) { func TestServiceCreatePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() t.Run("rejects invalid space id", func(t *testing.T) { @@ -462,7 +462,7 @@ func TestServiceCreatePage(t *testing.T) { t.Run("rejects parent in a different space", func(t *testing.T) { otherChannelID := mmmodel.NewId() - otherSpace := mustCreateSpace(t, h.store, otherChannelID) + otherSpace := mustCreateSpace(t, h.store, h.db, otherChannelID) parent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") _, err := h.svc.CreatePage(space.Id, parent.Id, "Title", "", "", userID) require.NotNil(t, err) @@ -472,7 +472,7 @@ func TestServiceCreatePage(t *testing.T) { t.Run("rejects exceeding max depth", func(t *testing.T) { // Own channel/space so the deep chain does not pollute the shared fixture. depthChannelID := mmmodel.NewId() - depthSpace := mustCreateSpace(t, h.store, depthChannelID) + depthSpace := mustCreateSpace(t, h.store, h.db, depthChannelID) // Build a full-depth chain (root at depth 1 up to MaxPageDepth); the next // child would be at depth MaxPageDepth+1 and must be rejected. parentID := "" @@ -520,7 +520,7 @@ func TestServiceUpdatePageNothingToUpdate(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") _, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{}, new(created.EditAt), false, mmmodel.NewId()) @@ -535,7 +535,7 @@ func TestServiceUpdatePageOversizedBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") oversized := strings.Repeat("x", model.PageBodyMaxBytes+1) @@ -551,7 +551,7 @@ func TestServiceUpdatePageOversizedSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") oversized := strings.Repeat("x", model.PageSearchTextMaxBytes+1) @@ -567,7 +567,7 @@ func TestServiceCreatePageOversizedBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) oversized := strings.Repeat("x", model.PageBodyMaxBytes+1) _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, "", mmmodel.NewId()) @@ -582,7 +582,7 @@ func TestServiceUpdatePageCanSetEmptyBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -605,7 +605,7 @@ func TestServiceCreatePageSearchTextWithoutBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) _, err := h.svc.CreatePage(space.Id, "", "Title", "", "searchtext", mmmodel.NewId()) require.NotNil(t, err) @@ -619,7 +619,7 @@ func TestServiceGetPageWithDeleted(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) actorID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, actorID, "") requireStoreDeletePage(t, h.store, created.Id, created.SpaceId, actorID) @@ -661,7 +661,7 @@ func TestServiceDeletePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() t.Run("deletes a page so the live get returns 404", func(t *testing.T) { @@ -704,7 +704,7 @@ func TestServiceRestorePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() t.Run("restores a soft-deleted page", func(t *testing.T) { @@ -769,7 +769,7 @@ func TestServiceRestoreSpace(t *testing.T) { h := openTestService(t) t.Run("restores a soft-deleted space", func(t *testing.T) { - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) require.Nil(t, h.svc.DeleteSpace(space)) got, err := h.svc.RestoreSpace(space.Id) @@ -778,7 +778,7 @@ func TestServiceRestoreSpace(t *testing.T) { }) t.Run("a non-deleted space returns 409", func(t *testing.T) { - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, err := h.svc.RestoreSpace(space.Id) require.NotNil(t, err) require.Equal(t, http.StatusConflict, err.StatusCode) @@ -794,12 +794,12 @@ func TestServiceRestoreSpace(t *testing.T) { t.Run("restoring over a channel a new live space owns returns 409", func(t *testing.T) { channelID := mmmodel.NewId() - original := mustCreateSpace(t, h.store, channelID) + original := mustCreateSpace(t, h.store, h.db, channelID) require.Nil(t, h.svc.DeleteSpace(original)) // A new live space now owns the backing channel; restoring the original would breach // the partial unique index uq_docs_space_channel_id. - mustCreateSpace(t, h.store, channelID) + mustCreateSpace(t, h.store, h.db, channelID) _, err := h.svc.RestoreSpace(original.Id) require.NotNil(t, err) diff --git a/server/app/space_test.go b/server/app/space_test.go index c2f7304..39836e9 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -30,6 +30,7 @@ import ( func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness { t.Helper() h := openTestService(t) + testutil.StubDefaultSpacePermissions(mockAPI) mockAPI.On("GetConfig").Return(&mmmodel.Config{}).Maybe() // Mutations publish best-effort WS events through the client; tests that assert event // content override this with exact-argument expectations. @@ -50,7 +51,7 @@ func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness Return(&mmmodel.TeamMember{}, nil).Maybe() // plugintest flattens a log call's variadic pairs into the mock's argument list, so a stub only // matches calls with exactly that many arguments. Cover each shape the service emits: LogWarn - // with message plus two key/value pairs (CheckSpaceMembership/GetSpacesForTeam client-not-wired + // with message plus two key/value pairs (ResolveSpaceRead/GetSpacesForTeam client-not-wired // denials), LogWarn with message plus three pairs (DeleteSpace, restoreSpaceChannel), and // LogError with message plus four (archiveOrphanChannel). mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() @@ -59,6 +60,9 @@ func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness // and message plus four pairs (archiveOrphanChannel). mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + // CreateSpace assigns the creator SchemeAdmin via the scheme's resolved role-name string; no + // test asserts the exact roles argument, so a wildcard catch-all covers every create. + mockAPI.On("UpdateChannelMemberRoles", mock.Anything, mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() client := pluginapi.NewClient(mockAPI, nil) h.svc = app.New(h.store, &client.Log, client) return h @@ -74,6 +78,7 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.MatchedBy(func(ch *mmmodel.Channel) bool { return ch.Type == mmmodel.ChannelTypeSpace && ch.TeamId == teamID })).Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) @@ -81,7 +86,7 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { space := &model.Space{TeamId: teamID, Title: "Test Space"} - saved, appErr := h.svc.CreateSpace(space, userID) + saved, appErr := h.svc.CreateSpace(space, userID, nil, nil) require.Nil(t, appErr) require.Equal(t, backingChannelID, saved.ChannelId) require.Equal(t, userID, saved.CreatorId) @@ -101,7 +106,7 @@ func TestServiceCreateSpace_ChannelIdRejected(t *testing.T) { space := &model.Space{ChannelId: mmmodel.NewId(), TeamId: mmmodel.NewId(), Title: "Bad Space"} - _, appErr := h.svc.CreateSpace(space, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(space, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) mockAPI.AssertNotCalled(t, "CreateChannel") @@ -117,7 +122,7 @@ func TestServiceCreateSpace_ChannelCreationFails(t *testing.T) { mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Doomed"}, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Doomed"}, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, "app.space.create.backing_channel_failed.app_error", appErr.Id) mockAPI.AssertNotCalled(t, "AddChannelMember") @@ -133,6 +138,7 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { // expectations cannot be overridden, so this test wires its mocks from scratch. mockAPI := &plugintest.API{} h := openTestService(t) + testutil.StubDefaultSpacePermissions(mockAPI) mockAPI.On("GetConfig"). Return(&mmmodel.Config{SqlSettings: mmmodel.SqlSettings{DataSourceReplicas: []string{"replica"}}}).Maybe() mockAPI.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() @@ -142,14 +148,16 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("UpdateChannelMemberRoles", channelID, userID, mock.Anything).Return(&mmmodel.ChannelMember{}, nil) client := pluginapi.NewClient(mockAPI, nil) h.svc = app.New(h.store, &client.Log, client) - saved, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Replicated"}, userID) + saved, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Replicated"}, userID, nil, nil) require.Nil(t, appErr) require.Equal(t, channelID, saved.ChannelId) mockAPI.AssertNotCalled(t, "GetChannel", channelID) @@ -169,7 +177,7 @@ func TestServiceCreateSpace_InvalidInput(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - _, appErr := h.svc.CreateSpace(space, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(space, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) mockAPI.AssertNotCalled(t, "CreateChannel") @@ -180,7 +188,7 @@ func TestServiceCreateSpace_InvalidInput(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - _, appErr := h.svc.CreateSpace(nil, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(nil, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) mockAPI.AssertNotCalled(t, "CreateChannel") @@ -202,7 +210,7 @@ func TestServiceCreateSpace_NotTeamMember(t *testing.T) { mockAPI.On("GetTeamMember", teamID, userID). Return(nil, &mmmodel.AppError{Message: "not a member", StatusCode: http.StatusNotFound}) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, http.StatusForbidden, appErr.StatusCode) require.Equal(t, "app.space.create.not_team_member.app_error", appErr.Id) @@ -224,38 +232,35 @@ func TestServiceCreateSpace_FormerTeamMemberBlocked(t *testing.T) { mockAPI.On("GetTeamMember", teamID, userID). Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, http.StatusForbidden, appErr.StatusCode) require.Equal(t, "app.space.create.not_team_member.app_error", appErr.Id) mockAPI.AssertNotCalled(t, "CreateChannel") } -// TestCheckSpaceMembership_FormerTeamMemberBlocked verifies that access to a team's space ends -// with team membership: leaving a team does not remove the user from the space's backing -// channel, so a former team member still holds a ChannelMember row — the team gate (which must -// read DeleteAt, since core returns removed memberships without error) is what blocks them. -func TestCheckSpaceMembership_FormerTeamMemberBlocked(t *testing.T) { - h := openTestService(t) +// TestResolveSpaceRead_FormerTeamMemberDenied verifies that access to a team's space ends with +// team membership: leaving a team does not remove the user from the space's backing channel, so a +// former team member still holds a ChannelMember row — the team gate (which must read DeleteAt, +// since core returns removed memberships without error) is what blocks them. +func TestResolveSpaceRead_FormerTeamMemberDenied(t *testing.T) { mockAPI := &plugintest.API{} - mockAPI.On("GetConfig").Return(&mmmodel.Config{}).Maybe() - client := pluginapi.NewClient(mockAPI, nil) - h.svc = app.New(h.store, &client.Log, client) - teamID := mmmodel.NewId() userID := mmmodel.NewId() - space := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) - + // Registered before the harness, whose GetTeamMember catch-all returns an active membership: + // mock.Mock matches expectations in registration order. mockAPI.On("GetTeamMember", teamID, userID). Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) + h := openTestServiceWithAPI(t, mockAPI) - _, appErr := h.svc.CheckSpaceMembership(space.Id, userID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id) - // The team gate blocks before the channel membership is ever consulted, so the lingering + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, userID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution) + // The team gate blocks before any channel-scoped permission is consulted, so the lingering // ChannelMember row is irrelevant. - mockAPI.AssertNotCalled(t, "GetChannelMember", mock.Anything, mock.Anything) + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) } // TestServiceDeleteSpace_ArchivesBackingChannel verifies DeleteSpace archives the space's @@ -268,13 +273,14 @@ func TestServiceDeleteSpace_ArchivesBackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) mockAPI.On("DeleteChannel", backingChannelID).Return(nil) mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space)) @@ -297,6 +303,7 @@ func TestServiceDeleteSpace_ArchiveFailureTolerated(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -305,7 +312,7 @@ func TestServiceDeleteSpace_ArchiveFailureTolerated(t *testing.T) { mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space), "DeleteSpace must succeed even though the channel archive fails") @@ -330,6 +337,7 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -341,7 +349,7 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 100}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space)) @@ -369,6 +377,7 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -383,7 +392,7 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 100}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Stuck Restore"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Stuck Restore"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space)) @@ -412,6 +421,7 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -425,7 +435,7 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 0}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Never Archived"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Never Archived"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space)) @@ -450,8 +460,9 @@ func TestServiceCreateSpace_CompensatingDelete(t *testing.T) { // Pre-seed a live space that already owns collisionChannelID, so the second insert // trips the unique channel-id constraint and the row save fails. - mustCreateSpace(t, h.store, collisionChannelID) + mustCreateSpace(t, h.store, h.db, collisionChannelID) + testutil.MustSeedChannelScheme(t, h.db, collisionChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: collisionChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", collisionChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -459,7 +470,7 @@ func TestServiceCreateSpace_CompensatingDelete(t *testing.T) { space := &model.Space{TeamId: teamID, Title: "Doomed Space"} - _, appErr := h.svc.CreateSpace(space, userID) + _, appErr := h.svc.CreateSpace(space, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode) mockAPI.AssertCalled(t, "DeleteChannel", collisionChannelID) @@ -478,8 +489,9 @@ func TestServiceCreateSpace_CompensatingDeleteAlsoFails(t *testing.T) { // Pre-seed a live space that already owns collisionChannelID, so the second insert // trips the unique channel-id constraint and the row save fails. - mustCreateSpace(t, h.store, collisionChannelID) + mustCreateSpace(t, h.store, h.db, collisionChannelID) + testutil.MustSeedChannelScheme(t, h.db, collisionChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: collisionChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", collisionChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -488,7 +500,7 @@ func TestServiceCreateSpace_CompensatingDeleteAlsoFails(t *testing.T) { space := &model.Space{TeamId: teamID, Title: "Doomed Space"} - _, appErr := h.svc.CreateSpace(space, userID) + _, appErr := h.svc.CreateSpace(space, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode, "the original row-save conflict must surface even though the compensating archive also failed") mockAPI.AssertCalled(t, "DeleteChannel", collisionChannelID) @@ -504,13 +516,14 @@ func TestServiceCreateSpace_AddMemberFailedCompensates(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) mockAPI.On("DeleteChannel", backingChannelID).Return(nil) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, "app.space.create.add_member_failed.app_error", appErr.Id) // The orphan channel is removed; no space row is persisted for the team. @@ -534,6 +547,7 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -541,7 +555,7 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("RestoreChannel", backingChannelID).Return(nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space)) got, appErr := h.svc.RestoreSpace(space.Id) @@ -556,9 +570,9 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { // optimistic-lock baseline. func TestServiceUpdateSpace(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) - patched, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer("New Title"), Description: mmmodel.NewPointer("New Desc")}, new(space.UpdateAt), false) + patched, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer("New Title"), Description: mmmodel.NewPointer("New Desc")}, new(space.UpdateAt), false, "") require.Nil(t, appErr) require.Equal(t, "New Title", patched.Title) require.Equal(t, "New Desc", patched.Description) @@ -566,50 +580,50 @@ func TestServiceUpdateSpace(t *testing.T) { // A patch with only Icon leaves the previously-set Title/Description intact. The caller // passes its latest fetched record, mirroring the handler flow (membership gate re-fetches // the space on every request). - patched2, appErr := h.svc.UpdateSpace(patched, &model.SpacePatch{Icon: mmmodel.NewPointer("icon-data")}, new(patched.UpdateAt), false) + patched2, appErr := h.svc.UpdateSpace(patched, &model.SpacePatch{Icon: mmmodel.NewPointer("icon-data")}, new(patched.UpdateAt), false, "") require.Nil(t, appErr) require.Equal(t, "New Title", patched2.Title, "unspecified fields are preserved") require.Equal(t, "New Desc", patched2.Description) require.Equal(t, "icon-data", patched2.Icon) // An explicit empty string clears a field (a nil field would leave it unchanged). - patched3, appErr := h.svc.UpdateSpace(patched2, &model.SpacePatch{Description: mmmodel.NewPointer("")}, new(patched2.UpdateAt), false) + patched3, appErr := h.svc.UpdateSpace(patched2, &model.SpacePatch{Description: mmmodel.NewPointer("")}, new(patched2.UpdateAt), false, "") require.Nil(t, appErr) require.Equal(t, "", patched3.Description, "an explicit empty string clears the field") require.Equal(t, "New Title", patched3.Title) // A stale baseline is rejected as a conflict unless force is set. - _, appErr = h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Stale")}, new(space.UpdateAt), false) + _, appErr = h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Stale")}, new(space.UpdateAt), false, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) - forced, appErr := h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Forced")}, new(space.UpdateAt), true) + forced, appErr := h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Forced")}, new(space.UpdateAt), true, "") require.Nil(t, appErr) require.Equal(t, "Forced", forced.Title) // A whitespace-only title is rejected. - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(" ")}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(" ")}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.title_required.app_error", appErr.Id) // A title that exceeds SpaceTitleMaxRunes is rejected. longTitle := strings.Repeat("x", model.SpaceTitleMaxRunes+1) - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(longTitle)}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(longTitle)}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.title_too_long.app_error", appErr.Id) // A description that exceeds SpaceDescriptionMaxRunes is rejected with the documented error ID. longDesc := strings.Repeat("x", model.SpaceDescriptionMaxRunes+1) - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Description: mmmodel.NewPointer(longDesc)}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Description: mmmodel.NewPointer(longDesc)}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.description_too_long.app_error", appErr.Id) // An icon that exceeds SpaceIconMaxBytes is rejected with the documented error ID. largeIcon := strings.Repeat("i", model.SpaceIconMaxBytes+1) - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Icon: mmmodel.NewPointer(largeIcon)}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Icon: mmmodel.NewPointer(largeIcon)}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.icon_too_large.app_error", appErr.Id) @@ -619,9 +633,9 @@ func TestServiceUpdateSpace(t *testing.T) { // than silently bumping UpdateAt (mirroring PagePatch's nothing-to-update guard). func TestServiceUpdateSpace_NoChangesRejected(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) - _, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{}, new(space.UpdateAt), false) + _, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{}, new(space.UpdateAt), false, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "model.space.patch.nothing_to_update.app_error", appErr.Id) @@ -642,13 +656,14 @@ func TestGetSpaceWithDeleted(t *testing.T) { userID := mmmodel.NewId() channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) mockAPI.On("DeleteChannel", channelID).Return(nil) mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID, nil, nil) require.Nil(t, appErr) require.Nil(t, h.svc.DeleteSpace(space)) @@ -664,136 +679,41 @@ func TestGetSpaceWithDeleted(t *testing.T) { require.NotZero(t, got.DeleteAt) } -// TestCheckSpaceMembership_MemberAllowed verifies that a user who is a member of the -// space's backing channel is allowed through. -func TestCheckSpaceMembership_MemberAllowed(t *testing.T) { +// TestResolveSpaceRead_MemberAdmitted verifies that a user who holds read_page on the space's +// backing channel is admitted as a member rather than via the open-space fall-through. +func TestResolveSpaceRead_MemberAdmitted(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) + // The harness stubs GetTeamMember to an active membership and read_page to true. teamID := mmmodel.NewId() userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - - mockAPI.On("GetChannelMember", space.ChannelId, userID).Return(&mmmodel.ChannelMember{}, nil) - - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, false) + resolution, appErr := h.svc.ResolveSpaceRead("test", space, userID) require.Nil(t, appErr) + require.Equal(t, app.ReadViaMember, resolution) } -// TestCheckSpaceMembership_NonMemberBlocked verifies that a user who is not a member of the -// space's backing channel receives a 403 Forbidden. -func TestCheckSpaceMembership_NonMemberBlocked(t *testing.T) { +// TestResolveSpaceRead_NonMemberDeniedOnPrivateSpace verifies that an active team member who holds +// no channel-scoped read_page is denied on a private space — the open-space fall-through is the +// only non-member admission, and it does not apply here. +func TestResolveSpaceRead_NonMemberDeniedOnPrivateSpace(t *testing.T) { mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - strangerID := mmmodel.NewId() - mockAPI.On("GetChannelMember", space.ChannelId, strangerID). - Return(nil, &mmmodel.AppError{StatusCode: http.StatusNotFound}) - - _, appErr = h.svc.CheckSpaceMembership(space.Id, strangerID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id) -} - -// TestCheckSpaceMembership_EmptyUserIDRejected verifies that an empty userID is rejected rather -// than treated as a trusted system caller, so a caller that loses its user id fails closed. -func TestCheckSpaceMembership_EmptyUserIDRejected(t *testing.T) { - mockAPI := &plugintest.API{} + // Registered before the harness so it takes precedence over StubDefaultSpacePermissions' + // permissive catch-all: mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionToChannel", strangerID, mock.Anything, mmmodel.PermissionReadPage).Return(false) h := openTestServiceWithAPI(t, mockAPI) - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) + // The harness stubs GetTeamMember to an active membership, so the stranger clears the team + // gate and is denied purely on the channel-scoped check. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + space.ViewAccess = model.ViewAccessPrivate - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) + resolution, appErr := h.svc.ResolveSpaceRead("test", space, strangerID) require.Nil(t, appErr) - - returnedSpace, appErr := h.svc.CheckSpaceMembership(space.Id, "", false) - require.Nil(t, returnedSpace) - require.NotNil(t, appErr) - require.Equal(t, http.StatusBadRequest, appErr.StatusCode) - require.Equal(t, "app.space.access.invalid_user_id.app_error", appErr.Id) -} - -// TestCheckSpaceMembership_IncludeDeleted verifies that includeDeleted=true reaches a -// soft-deleted space for the membership check, while includeDeleted=false returns 404. -func TestCheckSpaceMembership_IncludeDeleted(t *testing.T) { - mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - mockAPI.On("DeleteChannel", channelID).Return(nil) - mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) - - // includeDeleted=false → GetSpace returns 404, which CheckSpaceMembership converts to 403 - // to prevent existence probing by non-members. - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - - // includeDeleted=true → GetSpaceWithDeleted finds the space; membership check proceeds. - mockAPI.On("GetChannelMember", space.ChannelId, userID).Return(&mmmodel.ChannelMember{}, nil) - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, true) - require.Nil(t, appErr) -} - -// TestCheckSpaceMembership_ChannelLookupFailed verifies that a non-404 error from -// GetChannelMember propagates as a 500 with the channel_lookup_failed error key. -func TestCheckSpaceMembership_ChannelLookupFailed(t *testing.T) { - mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - - mockAPI.On("GetChannelMember", space.ChannelId, userID). - Return(nil, &mmmodel.AppError{Id: "store.sql_channel.get_member.missing.app_error", StatusCode: http.StatusInternalServerError}) - - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) - require.Equal(t, "app.space.access.channel_lookup_failed.app_error", appErr.Id) + require.Equal(t, app.ReadDenied, resolution) } // createSpaceForMemberTests stands up a space with a mocked backing channel so the member-management @@ -804,11 +724,12 @@ func createSpaceForMemberTests(t *testing.T, h *testHarness, mockAPI *plugintest creatorID := mmmodel.NewId() channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, creatorID).Return(&mmmodel.ChannelMember{}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, creatorID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, creatorID, nil, nil) require.Nil(t, appErr) return space, creatorID } @@ -854,6 +775,8 @@ func TestServiceRemoveSpaceMember_RemoveFails(t *testing.T) { space, _ := createSpaceForMemberTests(t, h, mockAPI) targetID := mmmodel.NewId() + // The target-existence resolve runs before the last-member/last-admin guards. + mockAPI.On("GetChannelMember", space.ChannelId, targetID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetID}, nil) // The last-member guard scans the member list before removing; report another (active, // via the default GetTeamMember stub) member so the removal proceeds to the failing // DeleteChannelMember call. @@ -862,7 +785,7 @@ func TestServiceRemoveSpaceMember_RemoveFails(t *testing.T) { mockAPI.On("DeleteChannelMember", space.ChannelId, targetID). Return(&mmmodel.AppError{Id: "app.channel.remove_member.app_error", StatusCode: http.StatusInternalServerError}) - appErr := h.svc.RemoveSpaceMember(space, targetID) + appErr := h.svc.RemoveSpaceMember(space, targetID, "") require.NotNil(t, appErr) require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) require.Equal(t, "app.space.remove_member.failed.app_error", appErr.Id) @@ -877,10 +800,11 @@ func TestServiceRemoveSpaceMember_LastMemberRejected(t *testing.T) { space, _ := createSpaceForMemberTests(t, h, mockAPI) soleID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, soleID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: soleID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{{ChannelId: space.ChannelId, UserId: soleID}}, nil) - appErr := h.svc.RemoveSpaceMember(space, soleID) + appErr := h.svc.RemoveSpaceMember(space, soleID, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) require.Equal(t, "app.space.remove_member.last_member.app_error", appErr.Id) @@ -903,10 +827,11 @@ func TestServiceRemoveSpaceMember_LastActiveMemberRejected(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) space, _ := createSpaceForMemberTests(t, h, mockAPI) + mockAPI.On("GetChannelMember", space.ChannelId, activeID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: activeID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{{ChannelId: space.ChannelId, UserId: activeID}, {ChannelId: space.ChannelId, UserId: formerID}}, nil) - appErr := h.svc.RemoveSpaceMember(space, activeID) + appErr := h.svc.RemoveSpaceMember(space, activeID, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) require.Equal(t, "app.space.remove_member.last_member.app_error", appErr.Id) @@ -922,11 +847,12 @@ func TestServiceRemoveSpaceMember_FormerTeamMemberRemovable(t *testing.T) { activeID := mmmodel.NewId() formerID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, formerID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: formerID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{{ChannelId: space.ChannelId, UserId: activeID}, {ChannelId: space.ChannelId, UserId: formerID}}, nil) mockAPI.On("DeleteChannelMember", space.ChannelId, formerID).Return(nil) - appErr := h.svc.RemoveSpaceMember(space, formerID) + appErr := h.svc.RemoveSpaceMember(space, formerID, "") require.Nil(t, appErr) mockAPI.AssertCalled(t, "DeleteChannelMember", space.ChannelId, formerID) } @@ -960,7 +886,7 @@ func TestServiceMovePage_PublishesMovedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index afd7b33..ebbe30e 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -28,7 +28,7 @@ func TestServiceUpdatePage_PublishesUpdatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") title := "Renamed" @@ -48,7 +48,7 @@ func TestServiceDeletePage_PublishesDeletedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") require.Nil(t, h.svc.DeletePage(page.Id, space.Id, userID)) @@ -66,7 +66,7 @@ func TestServiceDeletePage_FailurePublishesNothing(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") appErr := h.svc.DeletePage(page.Id, mmmodel.NewId(), userID) @@ -84,7 +84,7 @@ func TestServiceRestorePage_PublishesRestoredEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") require.Nil(t, h.svc.DeletePage(page.Id, space.Id, userID)) @@ -104,7 +104,7 @@ func TestServiceDuplicatePage_PublishesDuplicatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") copyRoot, appErr := h.svc.DuplicatePage(page.Id, space, userID, false, nil, nil) @@ -128,12 +128,12 @@ func TestServiceMovePageToSpace_PublishesEventToBothChannels(t *testing.T) { teamID := mmmodel.NewId() userID := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, userID, "") - moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, userID) + moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, userID, "") require.Nil(t, appErr) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_moved_to_space", @@ -163,10 +163,10 @@ func TestServiceMovePageToSpace_NoOpPublishesNothing(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") - same, appErr := h.svc.MovePageToSpace(page.Id, space, space, nil, new(page.UpdateAt), false, userID) + same, appErr := h.svc.MovePageToSpace(page.Id, space, space, nil, new(page.UpdateAt), false, userID, "") require.Nil(t, appErr) require.Equal(t, page.Id, same.Id) @@ -193,13 +193,13 @@ func TestServiceUpdateSpace_PublishesUpdatedEvent(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) // The post-update channel-metadata sync looks the backing channel up; a nil channel makes it // a no-op without needing channel-update expectations. mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil) title := "Renamed Space" - updated, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: &title}, new(space.UpdateAt), false) + updated, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: &title}, new(space.UpdateAt), false, "") require.Nil(t, appErr) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_updated", @@ -217,7 +217,7 @@ func TestServiceDeleteSpace_PublishesDeletedEvent(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) memberA := mmmodel.NewId() memberB := mmmodel.NewId() mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). @@ -248,7 +248,7 @@ func TestServiceDeleteSpace_SnapshotFailureFallsBackToChannelBroadcast(t *testin h := openTestServiceWithAPI(t, mockAPI) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: 500}) mockAPI.On("DeleteChannel", channelID).Return(nil) @@ -283,7 +283,7 @@ func TestServiceRestoreSpace_PublishesRestoredEvent(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("DeleteChannel", channelID).Return(nil) mockAPI.On("RestoreChannel", channelID).Return(nil) @@ -326,6 +326,7 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { space, creatorID := createSpaceForMemberTests(t, h, mockAPI) targetID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, targetID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{ {ChannelId: space.ChannelId, UserId: creatorID}, @@ -333,7 +334,7 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { }, nil) mockAPI.On("DeleteChannelMember", space.ChannelId, targetID).Return(nil) - require.Nil(t, h.svc.RemoveSpaceMember(space, targetID)) + require.Nil(t, h.svc.RemoveSpaceMember(space, targetID, "")) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_removed", map[string]any{"space_id": space.Id, "user_id": targetID}, From b153f831c22c1779d68fbafbd48957a9547a6bc9 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:53 +0200 Subject: [PATCH 08/28] Gate space and page routes on space permissions --- assets/i18n/en.json | 92 ++- server/api.go | 188 +++++- server/api_handler_test.go | 1121 +++++++++++++++++++++++++++++++----- server/api_page.go | 118 +++- server/api_space.go | 129 ++++- 5 files changed, 1455 insertions(+), 193 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a18af74..4370fa9 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -23,6 +23,10 @@ "id": "api.request_too_large.app_error", "translation": "The request body exceeds the maximum allowed size of {{.MaxBytes}} bytes." }, + { + "id": "api.space.add_member.capabilities_not_allowed.app_error", + "translation": "Capabilities cannot be set when adding a member. Add the member first, then set their capabilities." + }, { "id": "app.optimistic_lock.baseline_required.app_error", "translation": "The {{.Field}} field is required unless force is set." @@ -147,6 +151,10 @@ "id": "app.page.move_to_space.invalid_user_id.app_error", "translation": "Invalid user ID." }, + { + "id": "app.page.move_to_space.subtree_not_owned.app_error", + "translation": "You can only move pages you created. This page, or one of its sub-pages, was created by someone else." + }, { "id": "app.page.not_found.app_error", "translation": "The page could not be found." @@ -223,10 +231,6 @@ "id": "app.space.access.forbidden.app_error", "translation": "You do not have permission to access this space." }, - { - "id": "app.space.access.invalid_user_id.app_error", - "translation": "Invalid user ID." - }, { "id": "app.space.access.team_lookup_failed.app_error", "translation": "Failed to verify team membership." @@ -235,6 +239,10 @@ "id": "app.space.add_member.failed.app_error", "translation": "Unable to add the member to the space." }, + { + "id": "app.space.auto_join.get_space_failed.app_error", + "translation": "Failed to look up the space." + }, { "id": "app.space.client_not_wired.app_error", "translation": "Unable to complete the request due to a server configuration error." @@ -243,6 +251,10 @@ "id": "app.space.create.add_member_failed.app_error", "translation": "Unable to add the creator to the backing channel for the space." }, + { + "id": "app.space.create.admin_role_failed.app_error", + "translation": "Failed to assign the space admin role to the creator." + }, { "id": "app.space.create.backing_channel_failed.app_error", "translation": "Unable to create the backing channel for the space." @@ -251,6 +263,10 @@ "id": "app.space.create.channel_id_not_allowed.app_error", "translation": "A channel ID must not be supplied when creating a space." }, + { + "id": "app.space.create.forbidden.app_error", + "translation": "You do not have permission to create a space in this team." + }, { "id": "app.space.create.invalid_team_id.app_error", "translation": "Invalid team ID." @@ -259,6 +275,10 @@ "id": "app.space.create.invalid_user_id.app_error", "translation": "Invalid user ID." }, + { + "id": "app.space.create.invalid_view_access.app_error", + "translation": "Invalid view access. Must be either \"open\" or \"private\"." + }, { "id": "app.space.create.nil_input.app_error", "translation": "A space must be supplied." @@ -267,10 +287,22 @@ "id": "app.space.create.not_team_member.app_error", "translation": "You must be a member of the team to create a space in it." }, + { + "id": "app.space.create.scheme_lookup_failed.app_error", + "translation": "Failed to look up the space's permission scheme." + }, { "id": "app.space.create.team_lookup_failed.app_error", "translation": "Unable to verify team membership." }, + { + "id": "app.space.default_capabilities.channel_scheme_missing.app_error", + "translation": "The space has no permission scheme." + }, + { + "id": "app.space.default_capabilities.repoint_failed.app_error", + "translation": "Failed to apply the new default capabilities." + }, { "id": "app.space.delete.invalid_id.app_error", "translation": "Invalid space ID." @@ -279,6 +311,10 @@ "id": "app.space.get.invalid_id.app_error", "translation": "Invalid space ID." }, + { + "id": "app.space.get_for_team.forbidden.app_error", + "translation": "You do not have permission to view spaces in this team." + }, { "id": "app.space.get_for_team.invalid_team_id.app_error", "translation": "Invalid team ID." @@ -303,10 +339,26 @@ "id": "app.space.list_members.failed.app_error", "translation": "Unable to list the members of the space." }, + { + "id": "app.space.lock_timeout.app_error", + "translation": "Another change to this space is in progress. Try again." + }, + { + "id": "app.space.member.admin_count_failed.app_error", + "translation": "Failed to count the space's administrators." + }, + { + "id": "app.space.member.guest_not_assignable.app_error", + "translation": "Capabilities cannot be assigned to a guest." + }, { "id": "app.space.member.invalid_user_id.app_error", "translation": "The supplied user ID is not valid." }, + { + "id": "app.space.member.last_admin.app_error", + "translation": "The last administrator of a space cannot be removed or demoted." + }, { "id": "app.space.member.not_team_member.app_error", "translation": "The specified user is not a member of the space's team." @@ -315,6 +367,10 @@ "id": "app.space.member.team_lookup_failed.app_error", "translation": "Unable to verify the user's team membership." }, + { + "id": "app.space.member.update_capabilities_failed.app_error", + "translation": "Failed to update the member's capabilities." + }, { "id": "app.space.member.user_not_found.app_error", "translation": "The specified user could not be found." @@ -347,6 +403,14 @@ "id": "app.space.update.invalid_id.app_error", "translation": "Invalid space ID." }, + { + "id": "app.space.update.invalid_view_access.app_error", + "translation": "Invalid view access. Must be either \"open\" or \"private\"." + }, + { + "id": "app.space.update.view_access_force.app_error", + "translation": "View access cannot be changed with force set." + }, { "id": "app.store.conflict.app_error", "translation": "The operation conflicts with the current state. Reload and try again." @@ -543,8 +607,28 @@ "id": "model.space.is_valid.update_at.app_error", "translation": "Invalid space update time." }, + { + "id": "model.space.is_valid.view_access.app_error", + "translation": "Invalid view access. Must be either \"open\" or \"private\"." + }, { "id": "model.space.patch.nothing_to_update.app_error", "translation": "The update contains no changes." + }, + { + "id": "model.space_capabilities.admin_not_a_default.app_error", + "translation": "The admin_space capability cannot be a space default." + }, + { + "id": "model.space_capabilities.delete_page_not_grantable.app_error", + "translation": "The delete_page capability cannot be granted on its own. Grant admin_space instead." + }, + { + "id": "model.space_capabilities.read_page_not_grantable.app_error", + "translation": "The read_page capability cannot be granted; every space member holds it." + }, + { + "id": "model.space_capabilities.unknown_capability.app_error", + "translation": "Unknown capability: {{.Capability}}." } ] diff --git a/server/api.go b/server/api.go index 287f347..3be4de1 100644 --- a/server/api.go +++ b/server/api.go @@ -22,9 +22,13 @@ import ( // /api/v1 prefix (full root: /plugins/com.mattermost.docs/api/v1/). // // Authorization: every route requires an authenticated user via MattermostAuthorizationRequired. -// All space- and page-scoped handlers additionally gate on backing-channel membership via -// CheckSpaceMembership (implemented). Per-page role ACLs (author vs. editor within a space) -// are not yet implemented and are deferred to a follow-up. +// Every space- and page-scoped handler additionally gates on the capability-based RBAC model: +// requireSpaceRead/requireSpacePagePerm for reads, gatePageWrite/gateDeleteOwnOrAny for page +// writes (with the open-space auto-join pre-step), requireSpaceManageGate for membership +// management and general space-field updates (where a patch touching ViewAccess is additionally +// admin-gated inside UpdateSpace, against the live row), and requireSpaceAdminGate/ +// requireSpaceDeleteGate for the space-wide exposure-policy and delete/restore operations. See +// server/app/permissions.go for the gate implementations. func (p *Plugin) initRouter() *mux.Router { router := mux.NewRouter() router.Use(p.MattermostAuthorizationRequired) @@ -45,6 +49,8 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/members", p.handleListSpaceMembers).Methods(http.MethodGet) api.HandleFunc("/spaces/{space_id}/members", p.handleAddSpaceMember).Methods(http.MethodPost) api.HandleFunc("/spaces/{space_id}/members/{user_id}", p.handleRemoveSpaceMember).Methods(http.MethodDelete) + api.HandleFunc("/spaces/{space_id}/members/{user_id}/capabilities", p.handleSetSpaceMemberCapabilities).Methods(http.MethodPatch) + api.HandleFunc("/spaces/{space_id}/default-capabilities", p.handleSetSpaceDefaultCapabilities).Methods(http.MethodPatch) // Page collection. api.HandleFunc("/spaces/{space_id}/pages", p.handleGetSpacePages).Methods(http.MethodGet) @@ -84,19 +90,183 @@ func (p *Plugin) MattermostAuthorizationRequired(next http.Handler) http.Handler }) } -// requireSpaceMembership calls CheckSpaceMembership and writes the error response when access is -// denied. Returns the fetched space and true on success so callers can reuse the already-loaded -// record instead of re-fetching it. includeDeleted must be true for restore -// operations where the space is soft-deleted at lookup time. -func (p *Plugin) requireSpaceMembership(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { - space, appErr := p.service.CheckSpaceMembership(spaceID, userID, includeDeleted) +// fetchSpaceForGate fetches spaceID (with soft-deleted rows included when includeDeleted is +// true) and maps a not-found lookup to the shared existence-hiding 403, so no enforcement helper +// ever needs to special-case a missing space differently from a denied one. Writes the error +// response and returns ok=false on failure. +func (p *Plugin) fetchSpaceForGate(w http.ResponseWriter, spaceID string, includeDeleted bool) (*model.Space, bool) { + var space *model.Space + var appErr *mmmodel.AppError + if includeDeleted { + space, appErr = p.service.GetSpaceWithDeleted(spaceID) + } else { + space, appErr = p.service.GetSpace(spaceID) + } if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + p.writeAppError(w, mmmodel.NewAppError("fetchSpaceForGate", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(appErr)) + return nil, false + } p.writeAppError(w, appErr) return nil, false } return space, true } +// requireSpaceGate fetches spaceID and applies gate to it, writing the error response and +// returning ok=false on either a failed fetch or a denied gate. Every space-scoped route gate +// below is this shape — only the enforcement helper and its operation label differ. +func (p *Plugin) requireSpaceGate(w http.ResponseWriter, spaceID string, includeDeleted bool, gate func(space *model.Space) *mmmodel.AppError) (*model.Space, bool) { + space, ok := p.fetchSpaceForGate(w, spaceID, includeDeleted) + if !ok { + return nil, false + } + if appErr := gate(space); appErr != nil { + p.writeAppError(w, appErr) + return nil, false + } + return space, true +} + +// requireSpaceRead gates a route on the space read resolver (member, or the non-member open-space +// team fall-through) — the gate for every page read. It reports how the read was admitted so a +// caller that goes on to evaluate a further permission on the same space can pass the resolution +// down instead of re-deriving the team membership behind it. +func (p *Plugin) requireSpaceRead(w http.ResponseWriter, spaceID, userID string) (*model.Space, app.ReadResolution, bool) { + space, ok := p.fetchSpaceForGate(w, spaceID, false) + if !ok { + return nil, app.ReadDenied, false + } + resolution, ok := p.resolveSpaceReadOrDeny(w, "api.space.read", space, userID) + if !ok { + return nil, app.ReadDenied, false + } + return space, resolution, true +} + +// requireSpacePagePerm gates a route on a single page-scoped permission (read-only; write +// permissions go through gatePageWrite so the auto-join pre-step can run first). +func (p *Plugin) requireSpacePagePerm(w http.ResponseWriter, spaceID, userID string, perm *mmmodel.Permission) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpacePagePermission("api.space.page", space, userID, perm) + }) +} + +// requireSpaceManageGate gates a route on requireSpaceManage: sysadmin, channel admin_space, or +// (once the read resolver has already admitted the caller) team manage_space. +func (p *Plugin) requireSpaceManageGate(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpaceManage("api.space.manage", space, userID) + }) +} + +// requireSpaceAdminGate gates a route on requireSpaceAdminOrSysadmin — the space-wide +// exposure-policy knobs (ViewAccess, default capabilities). +func (p *Plugin) requireSpaceAdminGate(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpaceAdminOrSysadmin("api.space.admin", space, userID) + }) +} + +// requireSpaceDeleteGate gates space delete/restore: sysadmin, channel admin_space, or (once the +// read resolver has already admitted the caller) team delete_space. includeDeleted must be true +// for restore, where the space is soft-deleted at lookup time; the read resolver and the delete +// gate then evaluate against that soft-deleted record. +func (p *Plugin) requireSpaceDeleteGate(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, includeDeleted, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpaceDeleteAuthority("api.space.delete", space, userID) + }) +} + +// gatePageWrite resolves the read gate first — a caller cannot be granted write authority over a +// space it cannot read — then runs the auto-join pre-step when that read was admitted only via the +// non-member open-space fall-through, then re-resolves perm as a (possibly just-joined) member. +// ownerCheck, when non-nil, additionally must hold before a join happens (used for +// delete_own_page). Writes the error response and returns false on any denial. +func (p *Plugin) gatePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, ownerCheck func() (bool, error)) bool { + resolution, ok := p.resolveSpaceReadOrDeny(w, "gatePageWrite", space, userID) + if !ok { + return false + } + if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, ownerCheck); appErr != nil { + p.writeAppError(w, appErr) + return false + } + if appErr := p.service.RequireSpacePagePermissionFrom("api.page.write", space, userID, perm, resolution); appErr != nil { + p.writeAppError(w, appErr) + return false + } + return true +} + +// gateDeleteOwnOrAny gates a delete-class page operation: delete_page (any), or delete_own_page +// when ownerID == userID. The auto-join pre-step runs against delete_own_page, gated on +// ownership, since only that path can admit a non-member write. +func (p *Plugin) gateDeleteOwnOrAny(w http.ResponseWriter, space *model.Space, userID, ownerID string) bool { + resolution, ok := p.resolveSpaceReadOrDeny(w, "gateDeleteOwnOrAny", space, userID) + if !ok { + return false + } + if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }); appErr != nil { + p.writeAppError(w, appErr) + return false + } + _, ok, permErr := p.resolveOwnOrAny(space, userID, "api.page.delete", mmmodel.PermissionDeletePage, "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) + if permErr != nil { + p.writeAppError(w, permErr) + return false + } + if !ok { + p.writeAppError(w, mmmodel.NewAppError("gateDeleteOwnOrAny", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + return false + } + return true +} + +// resolveSpaceReadOrDeny resolves the read gate that precedes every page read and page-write gate, +// mapping a denied read to the shared existence-hiding 403. Writes the error response and returns +// ok=false on failure. +func (p *Plugin) resolveSpaceReadOrDeny(w http.ResponseWriter, where string, space *model.Space, userID string) (app.ReadResolution, bool) { + resolution, resErr := p.service.ResolveSpaceRead(where, space, userID) + if resErr != nil { + p.writeAppError(w, resErr) + return app.ReadDenied, false + } + if resolution == app.ReadDenied { + p.writeAppError(w, mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + return app.ReadDenied, false + } + return resolution, true +} + +// resolveOwnOrAny evaluates a two-tier own/any permission pair: anyPerm if held, else ownPerm +// when ownerMatches. Reports whether the caller qualified only via ownPerm (ownOnly), so a caller +// that must push ownership enforcement further down — MovePageToSpace's subtree-wide check — can +// tell the two tiers apart. ok=false with a nil appErr means neither tier admitted the caller; the +// caller writes its own denial so the operation label stays its own. A non-nil appErr is a genuine +// backend failure from the check itself, which the caller must surface as-is rather than reporting +// as a denial. +func (p *Plugin) resolveOwnOrAny(space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, admittedVia app.ReadResolution) (ownOnly, ok bool, appErr *mmmodel.AppError) { + anyErr := p.service.RequireSpacePagePermissionFrom(anyWhere, space, userID, anyPerm, admittedVia) + if anyErr == nil { + return false, true, nil + } + if anyErr.StatusCode != http.StatusForbidden { + return false, false, anyErr + } + if !ownerMatches { + return false, false, nil + } + ownErr := p.service.RequireSpacePagePermissionFrom(ownWhere, space, userID, ownPerm, admittedVia) + if ownErr == nil { + return true, true, nil + } + if ownErr.StatusCode != http.StatusForbidden { + return false, false, ownErr + } + return false, false, nil +} + // EnableDocsRequired is a middleware that rejects all API requests with 501 Not Implemented when // the EnableDocs feature flag is off. func (p *Plugin) EnableDocsRequired(next http.Handler) http.Handler { diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 3b558ef..9dc77d3 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -5,9 +5,9 @@ // router) over an isolated Postgres schema and drive handlers via httptest. CreateSpace needs a // pluginapi client, so that path uses a plugintest.API mock; the rest seed via the store directly. // -// Every space- and page-scoped handler enforces backing-channel membership (CheckSpaceMembership), -// returning 403 Forbidden for non-members. Per-page role ACLs (author vs. editor) are not yet -// implemented and are deferred to a follow-up. +// Every space- and page-scoped handler enforces the capability-based RBAC gates in +// server/app/permissions.go, returning 403 Forbidden for a caller the read resolver denies. +// Per-page role ACLs (author vs. editor) are not yet implemented and are deferred to a follow-up. package main import ( @@ -51,21 +51,27 @@ func openTestPlugin(t *testing.T, mockAPI *plugintest.API) *apiTestHarness { var client *pluginapi.Client if mockAPI == nil { - // Minimal stub: satisfies the EnableDocsRequired middleware and grants any user membership - // to any channel/team so space-scoped tests pass membership checks without a real server. - // Channel side-effects (archive/restore) are no-ops so store-only tests don't need to - // set up specific channel expectations. mockAPI = newEnabledMockAPI() - mockAPI.On("GetChannelMember", mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() - mockAPI.On("GetTeamMember", mock.Anything, mock.Anything).Return(&mmmodel.TeamMember{}, nil).Maybe() - mockAPI.On("DeleteChannel", mock.Anything).Return(nil).Maybe() - mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() - mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")).Return(mmmodel.ChannelMembers{}, nil).Maybe() - mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil).Maybe() } + // Minimal defaults: satisfy the EnableDocsRequired middleware and isActiveTeamMember/read + // checks so space-scoped tests pass membership checks without a real server. Channel + // side-effects (archive/restore) are no-ops so store-only tests don't need to set up specific + // channel expectations. .Maybe() and registered last, so any test-specific stub registered + // before openTestPlugin is called takes precedence. + mockAPI.On("GetChannelMember", mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("GetTeamMember", mock.Anything, mock.Anything).Return(&mmmodel.TeamMember{}, nil).Maybe() + mockAPI.On("DeleteChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")).Return(mmmodel.ChannelMembers{}, nil).Maybe() + mockAPI.On("GetChannelStats", mock.Anything).Return(&mmmodel.ChannelStats{}, nil).Maybe() + mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil).Maybe() // writeAppError logs 500-class failures (message plus four key/value pairs) regardless of // which mock a test supplies, so stub it universally. mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + testutil.StubDefaultSpacePermissions(mockAPI) + // CreateSpace assigns the creator SchemeAdmin via the scheme's resolved role-name string; no + // test asserts the exact roles argument, so a wildcard catch-all covers every create. + mockAPI.On("UpdateChannelMemberRoles", mock.Anything, mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() client = pluginapi.NewClient(mockAPI, nil) t.Cleanup(func() { mockAPI.AssertExpectations(t) }) @@ -87,6 +93,32 @@ func newEnabledMockAPI() *plugintest.API { return mockAPI } +// grantSpaceManage, grantSpaceAdmin, and grantSpaceDelete register an elevated-permission +// expectation for userID that MUST be added to mockAPI before it is passed to openTestPlugin: +// openTestPlugin registers testutil.StubDefaultSpacePermissions' default-deny catch-alls for +// manage_space/admin_space/delete_space, and mock.Mock matches expectations in registration +// order, so a stub added after openTestPlugin runs would never be reached. +func grantSpaceManage(mockAPI *plugintest.API, userID string) { + mockAPI.On("HasPermissionToTeam", userID, mock.Anything, mmmodel.PermissionManageSpace).Return(true) +} + +func grantSpaceDelete(mockAPI *plugintest.API, userID string) { + mockAPI.On("HasPermissionToTeam", userID, mock.Anything, mmmodel.PermissionDeleteSpace).Return(true) +} + +// grantSpaceAdmin registers a channel-level admin_space grant for userID on channelID — the +// exposure-policy gate (RequireSpaceAdminOrSysadmin), stricter than grantSpaceManage's team +// manage_space grant, which that gate deliberately does not accept. +func grantSpaceAdmin(mockAPI *plugintest.API, channelID, userID string) { + mockAPI.On("HasPermissionToChannel", userID, channelID, mmmodel.PermissionAdminSpace).Return(true) +} + +// grantSysadmin registers the system-wide manage_system override for userID, satisfying every +// space gate unconditionally. +func grantSysadmin(mockAPI *plugintest.API, userID string) { + mockAPI.On("HasPermissionTo", userID, mmmodel.PermissionManageSystem).Return(true) +} + // do issues a request through the plugin router. An empty userID omits the auth header. func (h *apiTestHarness) do(t *testing.T, method, path, userID string, body any) *httptest.ResponseRecorder { t.Helper() @@ -105,15 +137,19 @@ func (h *apiTestHarness) do(t *testing.T, method, path, userID string, body any) return rec } -func seedSpace(t *testing.T, s *store.Store, channelID string) *model.Space { +func seedSpace(t *testing.T, s *store.Store, db *sql.DB, channelID string) *model.Space { t.Helper() - return seedSpaceInTeam(t, s, channelID, mmmodel.NewId()) + return seedSpaceInTeam(t, s, db, channelID, mmmodel.NewId()) } -// seedSpaceInTeam mirrors testutil.MustCreateSpace's (channelID, teamID) parameter order. -func seedSpaceInTeam(t *testing.T, s *store.Store, channelID, teamID string) *model.Space { +// seedSpaceInTeam mirrors testutil.MustCreateSpace's (channelID, teamID) parameter order. db +// seeds a matching Channels stand-in row pointed at the contribute preset scheme, mirroring +// production CreateSpace's default so scheme-resolving app/handler paths work in tests. +func seedSpaceInTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() - return testutil.MustCreateSpace(t, s, channelID, teamID) + space := testutil.MustCreateSpace(t, s, channelID, teamID) + testutil.MustSeedChannelScheme(t, db, channelID, mmmodel.SchemeNameSpaceContribute) + return space } func seedPage(t *testing.T, s *store.Store, spaceID, channelID, parentID string) *model.Page { @@ -147,11 +183,15 @@ func TestHandler_CreateSpace(t *testing.T) { mockAPI := newEnabledMockAPI() mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) + backingChannelID := mmmodel.NewId() mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace}, nil) + Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("UpdateChannelMemberRoles", backingChannelID, mock.Anything, mock.Anything). + Return(&mmmodel.ChannelMember{}, nil) h := openTestPlugin(t, mockAPI) + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) teamID := mmmodel.NewId() rec := h.do(t, http.MethodPost, "/api/v1/teams/"+teamID+"/spaces", mmmodel.NewId(), map[string]any{ @@ -172,13 +212,17 @@ func TestHandler_CreateSpace_IgnoresServerOwnedFields(t *testing.T) { mockAPI := newEnabledMockAPI() mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) + backingChannelID := mmmodel.NewId() mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace}, nil) + Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("UpdateChannelMemberRoles", backingChannelID, mock.Anything, mock.Anything). + Return(&mmmodel.ChannelMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) h := openTestPlugin(t, mockAPI) + testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) forgedID := mmmodel.NewId() teamID := mmmodel.NewId() @@ -208,7 +252,7 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) t.Run("get space", func(t *testing.T) { rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, user, nil) @@ -238,14 +282,17 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { t.Run("get page in wrong space is 404", func(t *testing.T) { page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusNotFound, rec.Code) }) t.Run("move then delete page", func(t *testing.T) { parent := seedPage(t, h.store, space.Id, channelID, "") - page := seedPage(t, h.store, space.Id, channelID, "") + // Owned by the acting user: a contribute-default member holds only delete_own_page, not + // delete_page (any) — deleting a page owned by someone else is a different, admin-gated + // route, exercised separately. + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/move", user, map[string]any{ "parent_id": parent.Id, @@ -264,12 +311,16 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { }) } -// TestHandler_UpdateSpace patches a space's mutable fields. +// TestHandler_UpdateSpace patches a space's mutable fields. Updating a space is manage-gated +// : the acting user needs an elevated grant, not bare membership. func TestHandler_UpdateSpace(t *testing.T) { - h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + mockAPI := newEnabledMockAPI() + adminID := mmmodel.NewId() + grantSpaceManage(mockAPI, adminID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, mmmodel.NewId(), map[string]any{ + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ "title": "Renamed", "description": "New description", "expected_update_at": space.UpdateAt, @@ -281,19 +332,35 @@ func TestHandler_UpdateSpace(t *testing.T) { require.Equal(t, "New description", updated.Description) // A stale baseline now conflicts (the optimistic lock is client-supplied). - rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, mmmodel.NewId(), map[string]any{ + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ "title": "Stale", "expected_update_at": space.UpdateAt, }) require.Equal(t, http.StatusConflict, rec.Code) } -// TestHandler_DeleteSpace deletes a space; a follow-up read returns 403 (CheckSpaceMembership -// masks deleted-space existence to prevent probing via the status code). -func TestHandler_DeleteSpace(t *testing.T) { +// TestHandler_UpdateSpace_NonManageMemberForbidden verifies an ordinary contribute-default member +// (no manage_space/admin_space grant) cannot update a space's mutable fields. +func TestHandler_UpdateSpace_NonManageMemberForbidden(t *testing.T) { h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, mmmodel.NewId(), map[string]any{ + "title": "Renamed", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_DeleteSpace deletes a space; a follow-up read returns 403 (the gate masks +// deleted-space existence to prevent probing via the status code). Delete/restore requires team +// delete_space or channel admin_space. +func TestHandler_DeleteSpace(t *testing.T) { + mockAPI := newEnabledMockAPI() user := mmmodel.NewId() - space := seedSpace(t, h.store, mmmodel.NewId()) + grantSpaceDelete(mockAPI, user) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -305,9 +372,11 @@ func TestHandler_DeleteSpace(t *testing.T) { // TestHandler_RestoreSpace deletes then restores a space; a second restore of an already-live // space is rejected as a 409 (conflict — the space is already live). func TestHandler_RestoreSpace(t *testing.T) { - h := openTestPlugin(t, nil) + mockAPI := newEnabledMockAPI() user := mmmodel.NewId() - space := seedSpace(t, h.store, mmmodel.NewId()) + grantSpaceDelete(mockAPI, user) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -332,7 +401,7 @@ func TestHandler_RestoreSpace(t *testing.T) { func TestHandler_GetSpacePages(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) seedPage(t, h.store, space.Id, channelID, "") seedPage(t, h.store, space.Id, channelID, "") @@ -352,7 +421,7 @@ func TestHandler_PageCollectionsReturnMetadataOnly(t *testing.T) { h := openTestPlugin(t, nil) userID := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parentInput := testutil.NewPage(space.Id, channelID, userID, "") parentInput.Title = "Parent" @@ -407,23 +476,27 @@ func TestHandler_PageCollectionsReturnMetadataOnly(t *testing.T) { require.Contains(t, detail, "props") } -// TestHandler_ListSpaceMembers lists a space's members through the backing channel. +// TestHandler_ListSpaceMembers lists a space's members through the backing channel. The members +// list is manage-gated — deliberately stricter than MM channels, since the projection +// carries the per-member capability matrix. func TestHandler_ListSpaceMembers(t *testing.T) { channelID := mmmodel.NewId() memberUserID := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() mockAPI.On("GetChannelMembers", channelID, mock.AnythingOfType("int"), mock.AnythingOfType("int")). Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: memberUserID}}, nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members", mmmodel.NewId(), nil) + rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members", adminID, nil) require.Equal(t, http.StatusOK, rec.Code) var resp paginatedResponse[*model.SpaceMember] @@ -440,12 +513,14 @@ func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { channelID := mmmodel.NewId() firstMember := mmmodel.NewId() secondMember := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() // Page 0 at size 1 comes back full, so the handler probes the next page's first slot // (page-indexed API: index (page+1)*perPage at size 1). mockAPI.On("GetChannelMembers", channelID, 0, 1). @@ -454,9 +529,9 @@ func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: secondMember}}, nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members?per_page=1", mmmodel.NewId(), nil) + rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members?per_page=1", adminID, nil) require.Equal(t, http.StatusOK, rec.Code) var resp paginatedResponse[*model.SpaceMember] @@ -466,23 +541,26 @@ func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { require.True(t, resp.HasMore) } -// TestHandler_AddSpaceMember adds a member to a space; any current space member may do so. +// TestHandler_AddSpaceMember adds a member to a space; the caller needs requireSpaceManage — no +// longer any current space member. func TestHandler_AddSpaceMember(t *testing.T) { channelID := mmmodel.NewId() targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() mockAPI.On("AddChannelMember", channelID, targetUserID). Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID}, nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", mmmodel.NewId(), map[string]any{ + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", adminID, map[string]any{ "user_id": targetUserID, }) require.Equal(t, http.StatusCreated, rec.Code) @@ -493,12 +571,31 @@ func TestHandler_AddSpaceMember(t *testing.T) { mockAPI.AssertCalled(t, "AddChannelMember", channelID, targetUserID) } -// TestHandler_RemoveSpaceMember removes a member from a space. +// TestHandler_AddSpaceMember_NonManageMemberForbidden verifies an ordinary member without a +// manage grant cannot add another member. +func TestHandler_AddSpaceMember_NonManageMemberForbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", mmmodel.NewId(), map[string]any{ + "user_id": targetUserID, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_RemoveSpaceMember removes a member from a space; removing a non-self target +// requires requireSpaceManage — self-removal stays membership-gated, covered by +// TestHandler_RemoveSpaceMember_Self. func TestHandler_RemoveSpaceMember(t *testing.T) { channelID := mmmodel.NewId() targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). @@ -510,13 +607,49 @@ func TestHandler_RemoveSpaceMember(t *testing.T) { mockAPI.On("DeleteChannelMember", channelID, targetUserID).Return(nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, mmmodel.NewId(), nil) + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, adminID, nil) require.Equal(t, http.StatusOK, rec.Code) mockAPI.AssertCalled(t, "DeleteChannelMember", channelID, targetUserID) } +// TestHandler_RemoveSpaceMember_NonManageMemberForbidden verifies an ordinary member without a +// manage grant cannot remove another member. +func TestHandler_RemoveSpaceMember_NonManageMemberForbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, mmmodel.NewId(), nil) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_RemoveSpaceMember_Self verifies self-removal stays membership-gated: any member may +// leave the space they belong to, no manage grant required. +func TestHandler_RemoveSpaceMember_Self(t *testing.T) { + channelID := mmmodel.NewId() + selfID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). + Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: selfID}, {ChannelId: channelID, UserId: mmmodel.NewId()}}, nil) + mockAPI.On("DeleteChannelMember", channelID, selfID).Return(nil) + h := openTestPlugin(t, mockAPI) + + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+selfID, selfID, nil) + require.Equal(t, http.StatusOK, rec.Code) + mockAPI.AssertCalled(t, "DeleteChannelMember", channelID, selfID) +} + // TestHandler_GetTeamSpaces lists the spaces on a team visible to the caller. func TestHandler_GetTeamSpaces(t *testing.T) { channelID := mmmodel.NewId() @@ -527,7 +660,7 @@ func TestHandler_GetTeamSpaces(t *testing.T) { mockAPI.On("GetTeamMember", teamID, caller).Return(&mmmodel.TeamMember{}, nil) h := openTestPlugin(t, mockAPI) - seedSpaceInTeam(t, h.store, channelID, teamID) + seedSpaceInTeam(t, h.store, h.db, channelID, teamID) testutil.MustAddChannelMember(t, h.db, channelID, caller) rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) @@ -542,7 +675,7 @@ func TestHandler_UpdatePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") // Body and search text must be patched together (search text is the body's plain-text @@ -572,7 +705,7 @@ func TestHandler_UpdatePage_BaselineRequired(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, map[string]any{ @@ -599,7 +732,7 @@ func TestHandler_UpdatePage_Props(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") body := map[string]any{ @@ -619,8 +752,10 @@ func TestHandler_RestorePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - page := seedPage(t, h.store, space.Id, channelID, "") + space := seedSpace(t, h.store, h.db, channelID) + // Owned by the acting user: a contribute-default member holds only delete_own_page, not + // delete_page (any). + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -641,7 +776,7 @@ func TestHandler_GetPageChildren(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") childA := seedPage(t, h.store, space.Id, channelID, parent.Id) childB := seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -663,8 +798,8 @@ func TestHandler_GetPageChildren_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, channelID) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) parent := seedPage(t, h.store, space.Id, channelID, "") seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -677,10 +812,12 @@ func TestHandler_MovePageToSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) - page := seedPage(t, h.store, spaceA.Id, channelA, "") + spaceA := seedSpace(t, h.store, h.db, channelA) + // Owned by the acting user: move-to-space's source-side own-path requires ownership of the + // entire moved subtree, and a contribute-default member holds only delete_own_page. + page := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -699,7 +836,7 @@ func TestHandler_MovePageToSpace_MissingTargetSpaceId(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -717,7 +854,7 @@ func TestHandler_MovePageToSpace_InvalidTargetSpaceId(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -736,11 +873,11 @@ func TestHandler_MovePageToSpace_CrossTeamRejected(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") // A second space in a different team (seedSpace randomizes the team id per call). - spaceB := seedSpace(t, h.store, mmmodel.NewId()) + spaceB := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ "target_space_id": spaceB.Id, @@ -759,11 +896,13 @@ func TestHandler_MovePageToSpace_DepthExceeded(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) - page := seedPage(t, h.store, spaceA.Id, channelA, "") + spaceA := seedSpace(t, h.store, h.db, channelA) + // Owned by the acting user: move-to-space's source-side own-path requires ownership of the + // entire moved subtree, and a contribute-default member holds only delete_own_page. + page := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") channelB := mmmodel.NewId() - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: channelB, TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: channelB, TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) parentID := "" @@ -789,7 +928,7 @@ func TestHandler_MovePageToSpace_RejectsCycle(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) root := seedPage(t, h.store, spaceA.Id, channelA, "") child := seedPage(t, h.store, spaceA.Id, channelA, root.Id) @@ -810,11 +949,11 @@ func TestHandler_MovePageToSpace_ParentInWrongSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") parentInA := seedPage(t, h.store, spaceA.Id, channelA, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -834,7 +973,7 @@ func TestHandler_MovePage_MaxDepthExceeded(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parentID := "" for range app.MaxPageDepth { @@ -859,7 +998,7 @@ func TestHandler_MovePage_CircularReference(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) t.Run("self as parent", func(t *testing.T) { page := seedPage(t, h.store, space.Id, channelID, "") @@ -894,11 +1033,11 @@ func TestHandler_MovePage_ParentInDifferentSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") channelB := mmmodel.NewId() - spaceB := seedSpace(t, h.store, channelB) + spaceB := seedSpace(t, h.store, h.db, channelB) parentInB := seedPage(t, h.store, spaceB.Id, channelB, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move", user, map[string]any{ @@ -916,7 +1055,7 @@ func TestHandler_DuplicatePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/duplicate", user, nil) @@ -931,7 +1070,7 @@ func TestHandler_DuplicatePage_WithChildren(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -949,10 +1088,10 @@ func TestHandler_DuplicatePage_CrossSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/duplicate", user, map[string]any{ @@ -970,7 +1109,7 @@ func TestHandler_DuplicatePage_InvalidTargetSpaceId(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/duplicate", user, map[string]any{ @@ -987,7 +1126,7 @@ func TestHandler_DuplicatePage_WithParent(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/duplicate", user, map[string]any{ @@ -1004,9 +1143,9 @@ func TestHandler_DuplicatePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id+"/duplicate", user, nil) require.Equal(t, http.StatusNotFound, rec.Code) @@ -1017,9 +1156,9 @@ func TestHandler_UpdatePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id, user, map[string]any{ "title": "New Title", @@ -1033,9 +1172,9 @@ func TestHandler_DeletePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusNotFound, rec.Code) @@ -1046,9 +1185,9 @@ func TestHandler_RestorePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) // Soft-delete the page so restore has something to act on. _, delErr := h.store.DeletePage(page.Id, space.Id, user) @@ -1059,22 +1198,29 @@ func TestHandler_RestorePage_WrongSpaceIs404(t *testing.T) { } // TestHandler_SpaceMembershipRequired verifies that all space- and page-scoped handlers -// reject callers who are not members of the space's backing channel with 403 Forbidden. +// reject callers who are not members of a private space's backing channel with 403 Forbidden. +// Private, not the default open fixture: an open space deliberately admits non-member reads +// and auto-joins non-member default-granted writes, so this membership-required +// assertion only holds on a private space. func TestHandler_SpaceMembershipRequired(t *testing.T) { mockAPI := newEnabledMockAPI() - h := openTestPlugin(t, mockAPI) - channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - page := seedPage(t, h.store, space.Id, channelID, "") stranger := mmmodel.NewId() - // The stranger passes the team gate (an active member row) so the channel-membership - // rejection below is what each route exercises. + // The stranger passes the team gate (an active member row) so the lack of the read_page + // channel grant below is what each route exercises. Registered before openTestPlugin: mock + // matching is by registration order, so a stub added after openTestPlugin would be shadowed + // by StubDefaultSpacePermissions' permissive read_page-for-anyone catch-all. mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), stranger). Return(&mmmodel.TeamMember{}, nil) - mockAPI.On("GetChannelMember", channelID, stranger). - Return(nil, &mmmodel.AppError{StatusCode: http.StatusNotFound}) + mockAPI.On("HasPermissionToChannel", stranger, channelID, mmmodel.PermissionReadPage). + Return(false) + + h := openTestPlugin(t, mockAPI) + + space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: mmmodel.NewId(), CreatorId: mmmodel.NewId(), Title: "Private Space", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) + page := seedPage(t, h.store, space.Id, channelID, "") cases := []struct { method string @@ -1104,8 +1250,11 @@ func TestHandler_SpaceMembershipRequired(t *testing.T) { } } -// TestHandler_TeamSpacesPrivacy verifies that GET /teams/{team_id}/spaces filters out spaces -// whose backing channel the caller does not belong to. +// TestHandler_TeamSpacesPrivacy verifies that GET /teams/{team_id}/spaces filters out private +// spaces whose backing channel the caller does not belong to. An open space the caller doesn't +// belong to is deliberately still listed (the list-for-team predicate's open-space branch — +// members' spaces union open spaces the caller can read), so the hidden fixture here must be +// private, not merely non-member, for the exclusion to hold. func TestHandler_TeamSpacesPrivacy(t *testing.T) { mockAPI := newEnabledMockAPI() h := openTestPlugin(t, mockAPI) @@ -1113,8 +1262,9 @@ func TestHandler_TeamSpacesPrivacy(t *testing.T) { teamID := mmmodel.NewId() visibleChannelID := mmmodel.NewId() hiddenChannelID := mmmodel.NewId() - visible := seedSpaceInTeam(t, h.store, visibleChannelID, teamID) - _ = seedSpaceInTeam(t, h.store, hiddenChannelID, teamID) + visible := seedSpaceInTeam(t, h.store, h.db, visibleChannelID, teamID) + _, err := h.store.CreateSpace(&model.Space{ChannelId: hiddenChannelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Hidden", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) caller := mmmodel.NewId() mockAPI.On("GetTeamMember", teamID, caller). @@ -1143,9 +1293,13 @@ func TestHandler_TeamSpacesHiddenPageBoundary(t *testing.T) { hidden2ChannelID := mmmodel.NewId() caller := mmmodel.NewId() - visible := seedSpaceInTeam(t, h.store, visibleChannelID, teamID) - _ = seedSpaceInTeam(t, h.store, hidden1ChannelID, teamID) - _ = seedSpaceInTeam(t, h.store, hidden2ChannelID, teamID) + visible := seedSpaceInTeam(t, h.store, h.db, visibleChannelID, teamID) + // Private, not merely non-member: an open space the caller can't reach as a member is still + // listed via the list-for-team predicate's open-space branch. + _, err := h.store.CreateSpace(&model.Space{ChannelId: hidden1ChannelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Hidden 1", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) + _, err = h.store.CreateSpace(&model.Space{ChannelId: hidden2ChannelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Hidden 2", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) mockAPI.On("GetTeamMember", teamID, caller). Return(&mmmodel.TeamMember{TeamId: teamID, UserId: caller}, nil) @@ -1174,7 +1328,7 @@ func TestHandler_TeamSpacesPagination(t *testing.T) { caller := mmmodel.NewId() for range 3 { channelID := mmmodel.NewId() - _ = seedSpaceInTeam(t, h.store, channelID, teamID) + _ = seedSpaceInTeam(t, h.store, h.db, channelID, teamID) testutil.MustAddChannelMember(t, h.db, channelID, caller) } @@ -1194,15 +1348,17 @@ func TestHandler_TeamSpacesPagination(t *testing.T) { // denied with 403 (the team-membership gate on the listing route). func TestHandler_TeamSpacesNotTeamMember(t *testing.T) { mockAPI := newEnabledMockAPI() - h := openTestPlugin(t, mockAPI) - teamID := mmmodel.NewId() - _ = seedSpaceInTeam(t, h.store, mmmodel.NewId(), teamID) caller := mmmodel.NewId() + // Registered before openTestPlugin: mock matching is by registration order, so a stub added + // after openTestPlugin would be shadowed by its permissive default GetTeamMember catch-all. mockAPI.On("GetTeamMember", teamID, caller). Return(nil, &mmmodel.AppError{StatusCode: http.StatusNotFound}) + h := openTestPlugin(t, mockAPI) + _ = seedSpaceInTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) require.Equal(t, http.StatusForbidden, rec.Code) } @@ -1211,15 +1367,17 @@ func TestHandler_TeamSpacesNotTeamMember(t *testing.T) { // team-membership lookup propagates as 500. func TestHandler_TeamSpacesTeamLookupError(t *testing.T) { mockAPI := newEnabledMockAPI() - h := openTestPlugin(t, mockAPI) - teamID := mmmodel.NewId() - _ = seedSpaceInTeam(t, h.store, mmmodel.NewId(), teamID) caller := mmmodel.NewId() + // Registered before openTestPlugin: mock matching is by registration order, so a stub added + // after openTestPlugin would be shadowed by its permissive default GetTeamMember catch-all. mockAPI.On("GetTeamMember", teamID, caller). Return(nil, &mmmodel.AppError{StatusCode: http.StatusInternalServerError}) + h := openTestPlugin(t, mockAPI) + _ = seedSpaceInTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) require.Equal(t, http.StatusInternalServerError, rec.Code) } @@ -1227,7 +1385,7 @@ func TestHandler_TeamSpacesTeamLookupError(t *testing.T) { // TestHandler_InvalidJSON returns 400 on a malformed request body. func TestHandler_InvalidJSON(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", bytes.NewReader([]byte("{not json"))) req.Header.Set("Mattermost-User-ID", mmmodel.NewId()) @@ -1243,7 +1401,7 @@ func TestHandler_RequestTooLarge(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") // A well-formed JSON document whose "content" field alone exceeds maxPageBodyBytes, so the @@ -1301,7 +1459,7 @@ func TestHandler_MovePage_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") bad := "not-a-valid-id" @@ -1321,7 +1479,7 @@ func TestHandler_CreatePage_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) bad := "not-a-valid-id" rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", user, map[string]any{ @@ -1340,7 +1498,7 @@ func TestHandler_DuplicatePage_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") bad := "not-a-valid-id" @@ -1359,10 +1517,10 @@ func TestHandler_MovePageToSpace_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) bad := "not-a-valid-id" @@ -1382,7 +1540,7 @@ func TestHandler_GetSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -1399,7 +1557,7 @@ func TestHandler_GetPage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) @@ -1418,7 +1576,7 @@ func TestHandler_CreatePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", user, map[string]any{ "title": "Brand New Page", @@ -1444,8 +1602,10 @@ func TestHandler_DeletePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - page := seedPage(t, h.store, space.Id, channelID, "") + space := seedSpace(t, h.store, h.db, channelID) + // Owned by the acting user: a contribute-default member holds only delete_own_page, not + // delete_page (any). + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -1460,7 +1620,7 @@ func TestHandler_GetSpacePagesHasMoreBoundary(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) for range 3 { seedPage(t, h.store, space.Id, channelID, "") } @@ -1486,7 +1646,7 @@ func TestHandler_GetPageChildrenHasMoreBoundary(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") for range 3 { seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -1519,11 +1679,11 @@ func TestHandler_CreatePage_PublishesCreatedEvent(t *testing.T) { mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() mockAPI.On("PublishWebSocketEvent", "page_created", mock.Anything, mock.Anything).Return().Once() h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", mmmodel.NewId(), map[string]any{ "title": "Evented Page", }) @@ -1535,3 +1695,706 @@ func TestHandler_CreatePage_PublishesCreatedEvent(t *testing.T) { map[string]any{"page_id": created.Id, "space_id": space.Id, "parent_id": created.ParentId}, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) } + +// TestHandler_CreateSpace_RequiresCreateSpacePermission verifies that team membership alone does +// not authorize creating a space in it: a caller without team create_space (and not sysadmin) is +// rejected before any backing-channel side effect. TestHandler_CreateSpace (default-granted via +// StubDefaultSpacePermissions) covers the allowed path. +func TestHandler_CreateSpace_RequiresCreateSpacePermission(t *testing.T) { + teamID := mmmodel.NewId() + callerID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + mockAPI.On("GetTeamMember", teamID, callerID).Return(&mmmodel.TeamMember{}, nil) + // Registered before openTestPlugin so it takes precedence over StubDefaultSpacePermissions' + // permissive create_space catch-all (mock.Mock matches in registration order). + mockAPI.On("HasPermissionToTeam", callerID, teamID, mmmodel.PermissionCreateSpace).Return(false) + h := openTestPlugin(t, mockAPI) + + rec := h.do(t, http.MethodPost, "/api/v1/teams/"+teamID+"/spaces", callerID, map[string]any{ + "title": "Nope", + }) + require.Equal(t, http.StatusForbidden, rec.Code) + mockAPI.AssertNotCalled(t, "CreateChannel") +} + +// TestHandler_AddSpaceMember_GuestProjectsReadOnly verifies a guest added to a space is projected +// through the same reverse-role logic as ListSpaceMembers/SetSpaceMemberCapabilities: is_guest +// true and capabilities read_page-only, never the space default. +func TestHandler_AddSpaceMember_GuestProjectsReadOnly(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("AddChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeGuest: true}, nil) + h := openTestPlugin(t, mockAPI) + + // The contribute-preset default (seeded by seedSpace) must not leak into a guest's projection. + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", adminID, map[string]any{ + "user_id": targetUserID, + }) + require.Equal(t, http.StatusCreated, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.Equal(t, targetUserID, member.UserId) + require.True(t, member.IsGuest) + require.False(t, member.IsAdmin) + require.Equal(t, []string{model.CapabilityReadPage}, member.Capabilities) + require.Empty(t, member.GrantedCapabilities) +} + +// TestHandler_RemoveSpaceMember_NonSelfMissingTargetIsNotFound verifies that a manage-gated +// caller removing a non-member target always gets the plain 404 — even on a private space, where +// self-removal would get the existence-hiding 403 instead. A manage-gated caller has already +// proven manage authority over the space, so there is nothing left to existence-hide behind. +func TestHandler_RemoveSpaceMember_NonSelfMissingTargetIsNotFound(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + h := openTestPlugin(t, mockAPI) + + space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: mmmodel.NewId(), CreatorId: mmmodel.NewId(), Title: "Private Space", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) + testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, adminID, nil) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_Forbidden verifies a plain member without a manage grant +// cannot change another member's capabilities. +func TestHandler_SetSpaceMemberCapabilities_Forbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", mmmodel.NewId(), map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_GuestTargetRejected verifies a guest target is rejected +// with 400: guests stay read-only via the scheme's guest role and are never grant-assignable. +func TestHandler_SetSpaceMemberCapabilities_GuestTargetRejected(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeGuest: true}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.space.member.guest_not_assignable.app_error", appErr.Id) +} + +// TestHandler_SetSpaceMemberCapabilities_InvalidCapability verifies the granted-capability +// vocabulary is enforced: read_page (the non-grantable baseline) and an unknown token are both +// rejected with 400, before any target lookup. +func TestHandler_SetSpaceMemberCapabilities_InvalidCapability(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + cases := []struct { + name string + caps []string + }{ + {"read_page is not grantable", []string{"read_page"}}, + {"unknown token is rejected", []string{"not_a_real_capability"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": tc.caps, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +// TestHandler_SetSpaceMemberCapabilities_Grant covers the happy path: a manage-granted caller +// grants create_page to a plain member on a read-only-default space, and the response reflects +// both the granted set and the resulting effective capabilities (read_page plus the new grant). +func TestHandler_SetSpaceMemberCapabilities_Grant(t *testing.T) { + channelID := mmmodel.NewId() + teamID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + // A plain (non-admin, non-guest) target whose ExplicitRoles reflects the granted atomic role, + // standing in for the post-UpdateChannelMemberRoles state: the mock does not track writes, so + // the projection is pinned by the specific role token the requested capability set maps to. + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, ExplicitRoles: mmmodel.SpacePageCreatorRoleId}, nil) + h := openTestPlugin(t, mockAPI) + + space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "RO Space", ViewAccess: model.ViewAccessOpen}) + require.NoError(t, err) + testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceReadOnly) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.Equal(t, []string{"create_page"}, member.GrantedCapabilities) + require.ElementsMatch(t, []string{"read_page", "create_page"}, member.Capabilities) + require.False(t, member.IsAdmin) +} + +// TestHandler_SetSpaceMemberCapabilities_OmissionRevokesAdminForbidden verifies that a manage-only +// caller (no channel admin_space grant) cannot demote a current SchemeAdmin target by omitting +// admin_space from the requested set: the escalation guard fires on the current-holder side, not +// only when admin_space is newly requested. +func TestHandler_SetSpaceMemberCapabilities_OmissionRevokesAdminForbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_SelfTargetForbidden verifies a manage-only caller (no +// channel admin_space grant) cannot change their own capabilities: self-targeting always requires +// the stricter admin gate, regardless of what is requested. +func TestHandler_SetSpaceMemberCapabilities_SelfTargetForbidden(t *testing.T) { + channelID := mmmodel.NewId() + callerID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, callerID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+callerID+"/capabilities", callerID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_LastAdminConflict verifies removing admin_space from the +// space's sole authorized admin is rejected with 409, even for an admin-capable caller. +func TestHandler_SetSpaceMemberCapabilities_LastAdminConflict(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, nil) + // The last-admin guard scans the member list: only the sole target holds SchemeAdmin. + mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). + Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusConflict, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.space.member.last_admin.app_error", appErr.Id) +} + +// TestHandler_SetSpaceMemberCapabilities_EmptyDoesNotDemoteBelowDefault verifies the additive-only +// contract: granting the empty set to a plain member on a contribute-default space clears their +// per-member grant but never demotes their effective capabilities below the space default. +func TestHandler_SetSpaceMemberCapabilities_EmptyDoesNotDemoteBelowDefault(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.Empty(t, member.GrantedCapabilities) + require.ElementsMatch(t, + []string{model.CapabilityReadPage, model.CapabilityCommentPage, model.CapabilityCreatePage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage}, + member.Capabilities) +} + +// TestHandler_SetSpaceMemberCapabilities_PublishesEvent pins space_member_capabilities_updated: +// space/user payload, delivered both to the target user directly and to the backing channel. +func TestHandler_SetSpaceMemberCapabilities_PublishesEvent(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + // Built by hand instead of via newEnabledMockAPI: its wildcard PublishWebSocketEvent stub + // would swallow the pinned expectation below. + mockAPI := &plugintest.API{} + mockAPI.On("GetConfig").Return(&mmmodel.Config{FeatureFlags: &mmmodel.FeatureFlags{EnableDocs: true}}).Maybe() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("PublishWebSocketEvent", "space_member_capabilities_updated", mock.Anything, mock.Anything).Return() + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", + payload, &mmmodel.WebsocketBroadcast{UserId: targetUserID}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", + payload, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// stubSpaceSchemeRepoint wires GetChannelOfType/UpdateChannel so a SetSpaceDefaultCapabilities +// repoint against channelID is observable: the shared *model.Channel returned by GetChannelOfType +// is mutated in place by UpdateChannel's SchemeId argument, and that same value is written into +// the isolated test DB's Channels stand-in row, keeping subsequent store-side scheme resolution +// (GetSchemeRolesForChannel, spaceDefaultCapabilities — which read the DB row directly, not the +// mocked channel) consistent with the mocked channel across repeated calls in the same test. +// Must be registered before openTestPlugin (GetChannelOfType's default catch-all would otherwise +// shadow it — the same registration-order rule as grantSpaceManage/grantSpaceAdmin); hp is +// assigned by the caller once openTestPlugin returns, and the returned *model.Channel lets the +// caller seed a specific starting scheme id once the harness (and its seeded preset scheme ids) +// exists. +func stubSpaceSchemeRepoint(t *testing.T, mockAPI *plugintest.API, hp **apiTestHarness, channelID string) *mmmodel.Channel { + t.Helper() + channel := &mmmodel.Channel{Id: channelID, SchemeId: mmmodel.NewPointer(mmmodel.NewId())} + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace).Return(channel, nil) + // pluginapi's Channel.Update does `*channel = *updatedChannel` after this call, so the mock + // must return the same shared object (already mutated to the new SchemeId by the caller before + // Update runs) — returning a fresh, empty Channel would wipe the shared object's SchemeId back + // to nil on the very next GetChannelOfType read. Not every code path calls Update (a no-op + // resubmit never does), so this stub is Maybe(). + mockAPI.On("UpdateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + updated, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, updated.SchemeId) + _, dbErr := (*hp).db.Exec(`UPDATE Channels SET SchemeId = $1 WHERE Id = $2`, *updated.SchemeId, channelID) + require.NoError(t, dbErr) + }). + Return(channel, nil).Maybe() + return channel +} + +// TestHandler_SetSpaceDefaultCapabilities_Forbidden verifies a manage-only caller (team +// manage_space, no channel admin_space) cannot change a space's default capability set: the +// exposure-policy gate is stricter than ordinary manage. +func TestHandler_SetSpaceDefaultCapabilities_Forbidden(t *testing.T) { + mockAPI := newEnabledMockAPI() + userID := mmmodel.NewId() + // A manage-only grant, never exercised by this gate: RequireSpaceAdminOrSysadmin has no + // team-manage_space branch, so this stub is optional, not asserted. + mockAPI.On("HasPermissionToTeam", userID, mock.Anything, mmmodel.PermissionManageSpace).Return(true).Maybe() + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceDefaultCapabilities_Allowed covers the two callers admitted by the +// exposure-policy gate: a channel admin_space grant, and the system manage_system override. +func TestHandler_SetSpaceDefaultCapabilities_Allowed(t *testing.T) { + t.Run("channel admin_space grant", func(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + var h *apiTestHarness + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) + h = openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"comment_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("sysadmin override", func(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + var h *apiTestHarness + mockAPI := newEnabledMockAPI() + grantSysadmin(mockAPI, userID) + stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) + h = openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"comment_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + }) +} + +// TestHandler_SetSpaceDefaultCapabilities_InvalidCapability verifies the default-capability +// vocabulary is enforced: read_page (implicit baseline), admin_space (member-grant-only, never a +// space default), and an unknown token are all rejected with 400. +func TestHandler_SetSpaceDefaultCapabilities_InvalidCapability(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + cases := []struct { + name string + caps []string + }{ + {"read_page is implicit, not settable", []string{"read_page"}}, + {"admin_space is member-grant-only", []string{"admin_space"}}, + {"unknown token is rejected", []string{"not_a_real_capability"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": tc.caps, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +// TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme verifies a non-preset default +// capability set repoints the backing channel at a newly created space-private custom scheme +// (named with the docs_space_custom_ prefix), and the response echoes exactly the requested set. +func TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + var h *apiTestHarness + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) + h = openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var updated model.SpaceWithAccess + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, []string{"create_page"}, updated.DefaultCapabilities) + + var schemeName string + require.NoError(t, h.db.QueryRow( + `SELECT s.Name FROM Channels c JOIN Schemes s ON s.Id = c.SchemeId WHERE c.Id = $1`, channelID, + ).Scan(&schemeName)) + require.NotEmpty(t, schemeName) + require.False(t, mmmodel.IsSpaceSchemeName(schemeName), "expected a space-custom scheme, not a seeded preset, got %q", schemeName) +} + +// TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomScheme verifies that +// switching a space back to a preset default capability set repoints the channel at the shared +// preset scheme and retires the previously created custom scheme (and its roles) with no orphans +// left behind. +func TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomScheme(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + var h *apiTestHarness + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) + h = openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var customSchemeID string + require.NoError(t, h.db.QueryRow(`SELECT SchemeId FROM Channels WHERE Id = $1`, channelID).Scan(&customSchemeID)) + + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"comment_page", "create_page", "edit_page", "delete_own_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var contributeID string + require.NoError(t, h.db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, mmmodel.SchemeNameSpaceContribute).Scan(&contributeID)) + + var liveSchemeID string + require.NoError(t, h.db.QueryRow(`SELECT SchemeId FROM Channels WHERE Id = $1`, channelID).Scan(&liveSchemeID)) + require.Equal(t, contributeID, liveSchemeID) + + var schemeCount, roleCount int + require.NoError(t, h.db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, customSchemeID).Scan(&schemeCount)) + require.Zero(t, schemeCount, "the retired custom scheme must be gone") + require.NoError(t, h.db.QueryRow(`SELECT COUNT(*) FROM Roles WHERE SchemeId = $1`, customSchemeID).Scan(&roleCount)) + require.Zero(t, roleCount, "the retired custom scheme's roles must be gone") +} + +// TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp verifies that resubmitting the +// space's current default capability set (deduplicated and reordered) is a no-op: the backing +// channel's SchemeId does not change. +func TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + var h *apiTestHarness + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + channel := stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) + h = openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + // seedSpace already points the channel at the real contribute preset scheme (in the test DB); + // point the mocked channel at that same real id so the requested set — which normalizes to the + // same preset — resolves to a target matching the current scheme. + var contributeID string + require.NoError(t, h.db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, mmmodel.SchemeNameSpaceContribute).Scan(&contributeID)) + channel.SchemeId = mmmodel.NewPointer(contributeID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"edit_page", "comment_page", "comment_page", "create_page", "delete_own_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + mockAPI.AssertNotCalled(t, "UpdateChannel", mock.Anything) + + var afterSchemeID string + require.NoError(t, h.db.QueryRow(`SELECT SchemeId FROM Channels WHERE Id = $1`, channelID).Scan(&afterSchemeID)) + require.Equal(t, contributeID, afterSchemeID, "resubmitting the current set must not repoint the channel") +} + +// TestHandler_UpdateSpace_ViewAccessRequiresAdmin verifies that a manage-only caller (no channel +// admin_space grant) cannot change ViewAccess, and that a rejected ViewAccess change fails the +// whole request — the title in the same patch is not applied either. +func TestHandler_UpdateSpace_ViewAccessRequiresAdmin(t *testing.T) { + mockAPI := newEnabledMockAPI() + userID := mmmodel.NewId() + grantSpaceManage(mockAPI, userID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + require.Equal(t, model.ViewAccessOpen, space.ViewAccess) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, userID, map[string]any{ + "view_access": "private", + "title": "x", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusForbidden, rec.Code) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + var got model.Space + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "Test Space", got.Title, "the whole request must fail together; title must not be applied") + require.Equal(t, model.ViewAccessOpen, got.ViewAccess) +} + +// TestHandler_UpdateSpace_ViewAccessForceRejected verifies force=true is rejected on a ViewAccess +// change: the exposure-policy escalation check must always run, never last-write-wins. +func TestHandler_UpdateSpace_ViewAccessForceRejected(t *testing.T) { + mockAPI := newEnabledMockAPI() + userID := mmmodel.NewId() + grantSpaceManage(mockAPI, userID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, userID, map[string]any{ + "view_access": "open", + "force": true, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.space.update.view_access_force.app_error", appErr.Id) +} + +// TestHandler_UpdateSpace_ViewAccessAdminSucceedsMemberRetained verifies an admin-capable caller +// can flip a space from open to private, and that an existing member can still read the space +// afterwards (privatizing does not shed members). +func TestHandler_UpdateSpace_ViewAccessAdminSucceedsMemberRetained(t *testing.T) { + channelID := mmmodel.NewId() + adminID := mmmodel.NewId() + memberID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("AddChannelMember", channelID, memberID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: memberID}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", adminID, map[string]any{"user_id": memberID}) + require.Equal(t, http.StatusCreated, rec.Code) + + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ + "view_access": "private", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + var updated model.Space + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, model.ViewAccessPrivate, updated.ViewAccess) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, memberID, nil) + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestHandler_UpdateSpace_ViewAccessInvalidValue verifies an admin-capable caller supplying a +// nonsensical view_access value is rejected with 400. +func TestHandler_UpdateSpace_ViewAccessInvalidValue(t *testing.T) { + channelID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ + "view_access": "bogus", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +// TestHandler_OpenSpaceReadFallthrough_VisibleWithReadPublicChannel verifies a non-member of an +// open space with team read_public_channel sees it both in the team space listing and via the +// single-space read, the latter carrying only the read_page capability (never a hypothetical +// post-join grant). +func TestHandler_OpenSpaceReadFallthrough_VisibleWithReadPublicChannel(t *testing.T) { + teamID := mmmodel.NewId() + channelID := mmmodel.NewId() + caller := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + // Registered before openTestPlugin: mock matching is by registration order, so this specific + // stub (simulating "not a member") takes precedence over StubDefaultSpacePermissions' + // permissive read_page-for-anyone catch-all. + mockAPI.On("HasPermissionToChannel", caller, channelID, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("GetTeamMember", teamID, caller).Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + + space := seedSpaceInTeam(t, h.store, h.db, channelID, teamID) + require.Equal(t, model.ViewAccessOpen, space.ViewAccess) + + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) + require.Equal(t, http.StatusOK, rec.Code) + var list paginatedResponse[*model.Space] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Items, 1) + require.Equal(t, space.Id, list.Items[0].Id) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, caller, nil) + require.Equal(t, http.StatusOK, rec.Code) + var wrapper model.SpaceWithAccess + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &wrapper)) + require.Equal(t, []string{model.CapabilityReadPage}, wrapper.Capabilities) +} + +// TestHandler_OpenSpaceReadFallthrough_HiddenWithoutReadPublicChannel verifies the complementary +// case: the same non-member caller, but without team read_public_channel, sees the open space +// neither in the team listing nor via the single-space read (403). +func TestHandler_OpenSpaceReadFallthrough_HiddenWithoutReadPublicChannel(t *testing.T) { + teamID := mmmodel.NewId() + channelID := mmmodel.NewId() + caller := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + // Registered before openTestPlugin: same registration-order rule as above. + mockAPI.On("HasPermissionToChannel", caller, channelID, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("HasPermissionToTeam", caller, teamID, mmmodel.PermissionReadPublicChannel).Return(false) + mockAPI.On("GetTeamMember", teamID, caller).Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + + space := seedSpaceInTeam(t, h.store, h.db, channelID, teamID) + + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) + require.Equal(t, http.StatusOK, rec.Code) + var list paginatedResponse[*model.Space] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Empty(t, list.Items) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, caller, nil) + require.Equal(t, http.StatusForbidden, rec.Code) +} diff --git a/server/api_page.go b/server/api_page.go index d7f27f5..8f9cfc8 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -22,25 +22,30 @@ const maxPageBodyBytes = 8 << 20 // 8 MiB // IDs, booleans, and timestamps — no content fields. const maxPageStructBodyBytes = 4 * 1024 // 4 KiB -// resolveTargetSpace validates a target_space_id carried in the request body and checks the -// caller's membership. Unlike the {space_id} in the URL path — which every handler checks up front -// via requireSpaceMembership — a body-supplied ID only surfaces after decoding, so it is checked -// inline here. It writes the error response and returns ok=false on failure. Callers pass the -// invalid-ID rejection as a pre-built AppError with a string-literal ID so the i18n extraction -// tool can discover the message key. -func (p *Plugin) resolveTargetSpace(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, bool) { +// resolveTargetSpaceRead validates a target_space_id carried in the request body and resolves the +// caller's read access to it. Unlike the {space_id} in the URL path — which every handler checks +// up front — a body-supplied ID only surfaces after decoding, so it is checked inline here. It +// writes the error response and returns ok=false on failure. Callers pass the invalid-ID +// rejection as a pre-built AppError with a string-literal ID so the i18n extraction tool can +// discover the message key. +func (p *Plugin) resolveTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, bool) { if !mmmodel.IsValidId(targetSpaceID) { p.writeAppError(w, invalidIDErr) return nil, false } - return p.requireSpaceMembership(w, targetSpaceID, userID, false) + space, _, ok := p.requireSpaceRead(w, targetSpaceID, userID) + return space, ok } // handleCreatePage handles POST /api/v1/spaces/{space_id}/pages. func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, ok := p.fetchSpaceForGate(w, vars["space_id"], false) + if !ok { + return + } + if !p.gatePageWrite(w, space, userID, mmmodel.PermissionCreatePage, nil) { return } @@ -65,7 +70,7 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleGetPage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + if _, ok := p.requireSpacePagePerm(w, vars["space_id"], userID, mmmodel.PermissionReadPage); !ok { return } page, appErr := p.service.GetPageInSpace("handleGetPage", vars["page_id"], vars["space_id"], false) @@ -81,7 +86,11 @@ func (p *Plugin) handleGetPage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, ok := p.fetchSpaceForGate(w, vars["space_id"], false) + if !ok { + return + } + if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage, nil) { return } @@ -106,11 +115,21 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, updated) } -// handleDeletePage handles DELETE /api/v1/spaces/{space_id}/pages/{page_id}. +// handleDeletePage handles DELETE /api/v1/spaces/{space_id}/pages/{page_id}. Own/any: delete_page +// (any), or delete_own_page when the caller owns the page. func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, _, ok := p.requireSpaceRead(w, vars["space_id"], userID) + if !ok { + return + } + page, appErr := p.service.GetPageInSpace("handleDeletePage", vars["page_id"], vars["space_id"], false) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId) { return } if appErr := p.service.DeletePage(vars["page_id"], vars["space_id"], userID); appErr != nil { @@ -120,11 +139,21 @@ func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { writeStatusOK(w) } -// handleRestorePage handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/restore. +// handleRestorePage handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/restore. The page is +// soft-deleted at gate time, so the owner comparison resolves it via the include-deleted getter. func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, _, ok := p.requireSpaceRead(w, vars["space_id"], userID) + if !ok { + return + } + page, appErr := p.service.GetPageInSpace("handleRestorePage", vars["page_id"], vars["space_id"], true) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId) { return } restored, appErr := p.service.RestorePage(vars["page_id"], vars["space_id"], userID) @@ -139,11 +168,15 @@ func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { // (parent_id nil leaves the parent unchanged; "" moves to the space root) and positions it at // sibling_index within the destination sibling group (clamped to the group's bounds rather than // rejected if out of range). The optimistic-lock baseline (expected_update_at) is required unless -// force is set. +// force is set. Gated on edit_page. func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, ok := p.fetchSpaceForGate(w, vars["space_id"], false) + if !ok { + return + } + if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage, nil) { return } @@ -167,11 +200,12 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { // handleDuplicatePage handles POST /api/v1/spaces/{space_id}/pages/{page_id}/duplicate. An empty (or // all-default) body duplicates the page in place: same space, same parent, single page. // include_children copies the whole subtree; target_space_id/parent_id redirect the copy -// elsewhere. +// elsewhere. Gated on source read_page plus target create_page (target defaults to the source +// space, so an in-place duplicate is still create-gated). func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - sourceSpace, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false) + sourceSpace, _, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -186,12 +220,17 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { } // A nil targetSpace means "duplicate into the source space"; the fetched records are passed // through so the service never re-reads them. - var targetSpace *model.Space + targetSpace := sourceSpace if req.TargetSpaceId != "" && req.TargetSpaceId != vars["space_id"] { - if targetSpace, ok = p.resolveTargetSpace(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID); !ok { + var targetOK bool + targetSpace, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + if !targetOK { return } } + if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage, nil) { + return + } duplicated, appErr := p.service.DuplicatePage(vars["page_id"], sourceSpace, userID, req.IncludeChildren, targetSpace, req.ParentId) if appErr != nil { @@ -206,7 +245,7 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleGetPageChildren(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + if _, ok := p.requireSpacePagePerm(w, vars["space_id"], userID, mmmodel.PermissionReadPage); !ok { return } page, perPage := pageParam(r), perPageParam(r) @@ -221,11 +260,12 @@ func (p *Plugin) handleGetPageChildren(w http.ResponseWriter, r *http.Request) { // handleMovePageToSpace handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/move-to-space, // moving the page and its subtree to target_space_id (parent_id optional; "" = target root). // The optimistic-lock baseline (expected_update_at, the moved root's last-seen UpdateAt) is -// required unless force is set. +// required unless force is set. Gated on source read_page, a remove-class delete permission over +// the moved subtree, and target create_page. func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - sourceSpace, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false) + sourceSpace, sourceRead, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -247,12 +287,38 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { // move reuses the membership gate's record instead of resolving the same space twice. targetSpace := sourceSpace if req.TargetSpaceId != vars["space_id"] { - if targetSpace, ok = p.resolveTargetSpace(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID); !ok { + var targetOK bool + targetSpace, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + if !targetOK { return } } + // Source-side remove-class gate: delete_page (any) if held, else delete_own_page (the store + // enforces subtree-wide ownership in-transaction via requiredOwnerID). No source auto-join — + // only the target side admits a non-member write. Resolved before the target gate below, which + // can join the caller to the target space: a caller denied here must not be left holding a + // membership the rejected request created. + ownOnly, allowed, permErr := p.resolveOwnOrAny(sourceSpace, userID, + "api.page.move_to_space", mmmodel.PermissionDeletePage, + "api.page.move_to_space.own", mmmodel.PermissionDeleteOwnPage, true, sourceRead) + if permErr != nil { + p.writeAppError(w, permErr) + return + } + if !allowed { + p.writeAppError(w, mmmodel.NewAppError("handleMovePageToSpace", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + return + } + requiredOwnerID := "" + if ownOnly { + requiredOwnerID = userID + } + + if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage, nil) { + return + } - moved, appErr := p.service.MovePageToSpace(vars["page_id"], sourceSpace, targetSpace, req.ParentId, req.ExpectedUpdateAt, req.Force, userID) + moved, appErr := p.service.MovePageToSpace(vars["page_id"], sourceSpace, targetSpace, req.ParentId, req.ExpectedUpdateAt, req.Force, userID, requiredOwnerID) if appErr != nil { p.writeAppError(w, appErr) return diff --git a/server/api_space.go b/server/api_space.go index 9d284a2..437f955 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -14,6 +14,10 @@ import ( const maxSpaceBodyBytes = 1 << 20 // 1 MiB +// maxCapabilitiesBodyBytes caps the capability-set request bodies, which carry only tokens from a +// fixed five-value vocabulary — no content fields. +const maxCapabilitiesBodyBytes = 4 * 1024 // 4 KiB + // handleGetTeamSpaces handles GET /api/v1/teams/{team_id}/spaces. func (p *Plugin) handleGetTeamSpaces(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) @@ -34,9 +38,11 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { teamID := mux.Vars(r)["team_id"] var req struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + DefaultCapabilities *[]string `json:"default_capabilities,omitempty"` + ViewAccess *string `json:"view_access,omitempty"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleCreateSpace", false) { return @@ -51,7 +57,7 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { Description: req.Description, Icon: req.Icon, } - created, appErr := p.service.CreateSpace(space, userID) + created, appErr := p.service.CreateSpace(space, userID, req.DefaultCapabilities, req.ViewAccess) if appErr != nil { p.writeAppError(w, appErr) return @@ -59,25 +65,35 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, created) } -// handleGetSpace handles GET /api/v1/spaces/{space_id}. +// handleGetSpace handles GET /api/v1/spaces/{space_id}, returning the SpaceWithAccess wrapper +// carrying the space's default capability set and the caller's own effective capabilities. func (p *Plugin) handleGetSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + // BuildSpaceWithAccess resolves the read gate itself and returns the same existence-hiding 403 + // on a denial, so it is the gate here rather than a second resolution behind requireSpaceRead. + space, ok := p.fetchSpaceForGate(w, spaceID, false) if !ok { return } - writeJSON(w, http.StatusOK, space) + wrapper, appErr := p.service.BuildSpaceWithAccess(space, userID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + writeJSON(w, http.StatusOK, wrapper) } // handleUpdateSpace handles PATCH /api/v1/spaces/{space_id}. Only the supplied mutable fields -// (title, description, icon, props) are applied onto the existing space; a supplied empty string -// clears the field. The optimistic-lock baseline (expected_update_at) is required unless force -// is set. +// (title, description, icon, props, view_access) are applied onto the existing space; a supplied +// empty string clears a string field. The optimistic-lock baseline (expected_update_at) is +// required unless force is set. requireSpaceManageGate is the route floor; a ViewAccess change +// that requires the stricter admin gate is enforced inside UpdateSpace itself, against the live +// row, under the space's membership advisory lock. func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceManageGate(w, spaceID, userID) if !ok { return } @@ -87,14 +103,15 @@ func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { Description *string `json:"description"` Icon *string `json:"icon"` Props *mmmodel.StringInterface `json:"props"` + ViewAccess *string `json:"view_access"` ExpectedUpdateAt *int64 `json:"expected_update_at"` Force bool `json:"force"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleUpdateSpace", false) { return } - patch := &model.SpacePatch{Title: req.Title, Description: req.Description, Icon: req.Icon, Props: req.Props} - updated, appErr := p.service.UpdateSpace(space, patch, req.ExpectedUpdateAt, req.Force) + patch := &model.SpacePatch{Title: req.Title, Description: req.Description, Icon: req.Icon, Props: req.Props, ViewAccess: req.ViewAccess} + updated, appErr := p.service.UpdateSpace(space, patch, req.ExpectedUpdateAt, req.Force, userID) if appErr != nil { p.writeAppError(w, appErr) return @@ -106,7 +123,7 @@ func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleDeleteSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceDeleteGate(w, spaceID, userID, false) if !ok { return } @@ -122,7 +139,7 @@ func (p *Plugin) handleDeleteSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleRestoreSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - if _, ok := p.requireSpaceMembership(w, spaceID, userID, true); !ok { + if _, ok := p.requireSpaceDeleteGate(w, spaceID, userID, true); !ok { return } restored, appErr := p.service.RestoreSpace(spaceID) @@ -137,7 +154,7 @@ func (p *Plugin) handleRestoreSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleGetSpacePages(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpacePagePerm(w, spaceID, userID, mmmodel.PermissionReadPage) if !ok { return } @@ -154,7 +171,7 @@ func (p *Plugin) handleGetSpacePages(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleListSpaceMembers(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceManageGate(w, spaceID, userID) if !ok { return } @@ -167,21 +184,29 @@ func (p *Plugin) handleListSpaceMembers(w http.ResponseWriter, r *http.Request) writePaginatedJSON(w, members, page, perPage, hasMore) } -// handleAddSpaceMember handles POST /api/v1/spaces/{space_id}/members. Any current space member -// may add another user to the space. +// handleAddSpaceMember handles POST /api/v1/spaces/{space_id}/members. Adds the target at the +// space default only; granted_capabilities/capabilities in the body are rejected (400) rather +// than silently dropped, since a caller believing they restricted a new member's capabilities +// would otherwise be misled. func (p *Plugin) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceManageGate(w, spaceID, userID) if !ok { return } var req struct { - UserID string `json:"user_id"` + UserID string `json:"user_id"` + GrantedCapabilities *[]string `json:"granted_capabilities"` + Capabilities *[]string `json:"capabilities"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleAddSpaceMember", false) { return } + if req.GrantedCapabilities != nil || req.Capabilities != nil { + p.writeAppError(w, mmmodel.NewAppError("handleAddSpaceMember", "api.space.add_member.capabilities_not_allowed.app_error", nil, "", http.StatusBadRequest)) + return + } member, appErr := p.service.AddSpaceMember(space, req.UserID) if appErr != nil { p.writeAppError(w, appErr) @@ -190,18 +215,72 @@ func (p *Plugin) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, member) } -// handleRemoveSpaceMember handles DELETE /api/v1/spaces/{space_id}/members/{user_id}. Any current -// space member may remove another user from the space, except the last remaining member (409). +// handleSetSpaceMemberCapabilities handles PATCH /api/v1/spaces/{space_id}/members/{user_id}/capabilities. +func (p *Plugin) handleSetSpaceMemberCapabilities(w http.ResponseWriter, r *http.Request) { + userID := userIDFromRequest(r) + vars := mux.Vars(r) + spaceID := vars["space_id"] + targetUserID := vars["user_id"] + space, ok := p.requireSpaceManageGate(w, spaceID, userID) + if !ok { + return + } + var req struct { + GrantedCapabilities []string `json:"granted_capabilities"` + } + if !p.decodeJSONBody(w, r, maxCapabilitiesBodyBytes, &req, "handleSetSpaceMemberCapabilities", false) { + return + } + member, appErr := p.service.SetSpaceMemberCapabilities(space, targetUserID, req.GrantedCapabilities, userID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + writeJSON(w, http.StatusOK, member) +} + +// handleSetSpaceDefaultCapabilities handles PATCH /api/v1/spaces/{space_id}/default-capabilities. +func (p *Plugin) handleSetSpaceDefaultCapabilities(w http.ResponseWriter, r *http.Request) { + userID := userIDFromRequest(r) + spaceID := mux.Vars(r)["space_id"] + space, ok := p.requireSpaceAdminGate(w, spaceID, userID) + if !ok { + return + } + var req struct { + DefaultCapabilities []string `json:"default_capabilities"` + } + if !p.decodeJSONBody(w, r, maxCapabilitiesBodyBytes, &req, "handleSetSpaceDefaultCapabilities", false) { + return + } + updated, appErr := p.service.SetSpaceDefaultCapabilities(space, req.DefaultCapabilities, userID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + writeJSON(w, http.StatusOK, updated) +} + +// handleRemoveSpaceMember handles DELETE /api/v1/spaces/{space_id}/members/{user_id}. Self-removal +// is gated on the read resolver alone (any member may leave); removing another user requires +// requireSpaceManage, with the escalation/last-admin guards enforced inside the service. func (p *Plugin) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) vars := mux.Vars(r) spaceID := vars["space_id"] targetUserID := vars["user_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + + var space *model.Space + var ok bool + if targetUserID == userID { + space, _, ok = p.requireSpaceRead(w, spaceID, userID) + } else { + space, ok = p.requireSpaceManageGate(w, spaceID, userID) + } if !ok { return } - if appErr := p.service.RemoveSpaceMember(space, targetUserID); appErr != nil { + if appErr := p.service.RemoveSpaceMember(space, targetUserID, userID); appErr != nil { p.writeAppError(w, appErr) return } From fa20869f00169d9d628e9492a064ca8c3dcb49a5 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 17:42:53 +0200 Subject: [PATCH 09/28] Add containerized end-to-end suite and core image build --- .github/workflows/ci.yml | 40 +++ Makefile | 26 ++ build/build-core-image.sh | 140 ++++++++++ server/e2e/README.md | 55 ++++ server/e2e/container_test.go | 184 +++++++++++++ server/e2e/helpers_test.go | 172 +++++++++++++ server/e2e/scenarios_test.go | 487 +++++++++++++++++++++++++++++++++++ 7 files changed, 1104 insertions(+) create mode 100755 build/build-core-image.sh create mode 100644 server/e2e/README.md create mode 100644 server/e2e/container_test.go create mode 100644 server/e2e/helpers_test.go create mode 100644 server/e2e/scenarios_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9557847..edaff01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,46 @@ jobs: - name: ci/build uses: mattermost/actions/plugin-ci/build@0256d363493a1e6b9e361ea788c62a0a73bc00be + # End-to-end suite: boots a real Mattermost via Testcontainers, installs this plugin, and drives + # the space-permission scenarios through the real API with nothing mocked. + # + # It needs a server image carrying the paired core branch's space-permission changes. Core CI + # publishes one per commit as mattermostdevelopment/mattermost-team-edition:<7-char-sha>, so set + # the CORE_IMAGE repository variable to that tag (or to a release tag once those changes ship). + # While CORE_IMAGE is unset this job does not run: no available image could satisfy it, and a + # silent pass would be worse than an absent one. + e2e: + if: ${{ vars.CORE_IMAGE != '' && (github.repository_owner == 'mattermost' || github.event_name != 'schedule') }} + runs-on: ubuntu-latest + needs: build + timeout-minutes: 20 + permissions: + contents: read + env: + CORE_IMAGE: ${{ vars.CORE_IMAGE }} + steps: + - name: Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: ci/setup + uses: mattermost/actions/plugin-ci/setup@0256d363493a1e6b9e361ea788c62a0a73bc00be + with: + go-version-file: go.mod + + # Reuses the bundle the build job already produced rather than rebuilding it here; the + # suite globs dist/ for it. + - name: ci/download-artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: dist + path: dist + + - name: ci/e2e + run: go test -tags e2e -count=1 -v ./server/e2e/... + delivery: if: ${{ github.repository_owner == 'mattermost' && github.event_name != 'schedule' && github.ref_name == 'master' }} runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index b55ebb4..e9a0839 100644 --- a/Makefile +++ b/Makefile @@ -362,6 +362,32 @@ ifneq ($(HAS_WEBAPP),) cd webapp && $(NPM) run test; endif +## Runs the official RBAC end-to-end suite (Go + Testcontainers): boots a real Mattermost server +## built from the paired core branch (build/build-core-image.sh), installs the plugin bundle +## into it, and drives the seven Confluence permission scenarios plus their named parity gaps +## through the real HTTP API. Requires Docker. See server/e2e/README.md. +## +## The bundle must contain a linux-$(arch) plugin binary for the Docker container to load it — +## `make server` alone only builds for the host OS/arch when MM_SERVICESETTINGS_ENABLEDEVELOPER is +## set (a common local dev convenience), which produces a bundle unusable inside the container. So +## this checks the newest bundle for the linux binary matching the Docker daemon's architecture, +## not just that a bundle file exists, and forces a full cross-compiled `make dist` otherwise. +.PHONY: test-e2e +test-e2e: + @docker_arch=$$(docker info --format '{{.Architecture}}' 2>/dev/null); \ + case "$$docker_arch" in \ + aarch64) goarch=arm64 ;; \ + x86_64) goarch=amd64 ;; \ + *) echo "ERROR: could not determine Docker daemon architecture (got '$$docker_arch'). Is Docker running?" >&2; exit 1 ;; \ + esac; \ + bundle=$$(ls -t dist/$(PLUGIN_ID)-*.tar.gz 2>/dev/null | head -1); \ + if [ -z "$$bundle" ] || ! tar tzf "$$bundle" | grep -q "plugin-linux-$$goarch$$"; then \ + echo "No plugin bundle with a linux-$$goarch binary found — running 'make dist' (forcing an all-architecture build)..."; \ + MM_SERVICESETTINGS_ENABLEDEVELOPER= $(MAKE) dist; \ + fi + ./build/build-core-image.sh + $(GO) test -tags e2e -count=1 -v ./server/e2e/... + ## Creates a coverage report for the server code. .PHONY: coverage coverage: apply webapp/node_modules diff --git a/build/build-core-image.sh b/build/build-core-image.sh new file mode 100755 index 0000000..ae6c366 --- /dev/null +++ b/build/build-core-image.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# build-core-image.sh — build a Docker image of the docs-core server (the RBAC epic's paired core +# branch) so the Go e2e suite (server/e2e/) can boot a real Mattermost with the plugin installed via +# Testcontainers, without waiting for the branch to merge and ship a published image. +# +# What it does: +# 1. Guards that MM_SERVER_REPO is checked out on DOCS_CORE_BRANCH (never checks out for you). +# 2. Cross-compiles ./cmd/mattermost for the Docker daemon's architecture (so this works +# whether Docker is running amd64 or arm64 containers, independent of the host arch). +# 3. Assembles a minimal build context (binary + i18n/templates/fonts from the core tree, plus +# empty config/client/plugins/data/logs directories) in a temp dir. +# 4. Builds the image and tags it $CORE_IMAGE (default mm-docs-rbac-core:dev). +# +# Usage: +# ./scripts/build-core-image.sh +# CORE_IMAGE=my-tag:dev ./scripts/build-core-image.sh +# ./scripts/build-core-image.sh --skip-build # reuse the existing binary at $MM_SERVER_REPO/bin/mattermost +# +# Requires: +# - Docker running locally. +# - The core repo checked out on DOCS_CORE_BRANCH (this script refuses to run otherwise — it +# never switches branches for you; see start-docs-core-server.sh for the same guard style). + +set -euo pipefail + +# Configuration is read from the environment with committed defaults: this script must not depend +# on any developer's local, untracked setup. The local bash suites keep their own env.sh, which +# exports these same names, so sourcing it beforehand also works. +# +# MM_SERVER_REPO path to the core repo's server/ directory (required; auto-detected from the +# conventional sibling checkouts when unset) +# DOCS_CORE_BRANCH branch the core repo must be on (default below) +# CORE_IMAGE image tag to build (default below) +DOCS_CORE_BRANCH="${DOCS_CORE_BRANCH:-MM-69269-permissions-rbac-core}" +CORE_IMAGE="${CORE_IMAGE:-mm-docs-rbac-core:dev}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +if [[ -z "${MM_SERVER_REPO:-}" ]]; then + for candidate in "$REPO_ROOT/../MM-69269-core/server" "$REPO_ROOT/../mattermost/server"; do + if [[ -d "$candidate" ]]; then + MM_SERVER_REPO="$(cd "$candidate" && pwd)" + break + fi + done +fi +if [[ -z "${MM_SERVER_REPO:-}" || ! -d "$MM_SERVER_REPO" ]]; then + echo "ERROR: cannot locate the core repo. Set MM_SERVER_REPO to its server/ directory, e.g." >&2 + echo " MM_SERVER_REPO=/path/to/mattermost/server $0" >&2 + exit 1 +fi + +SKIP_BUILD=false +for arg in "$@"; do + [[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true +done + +# ── Guard: core repo must be on the docs core-complement branch ───────────────── +# We never check out for you: the core repo holds uncommitted work, and silently switching +# branches could lose or mix it (see git-safety rules). +CURRENT_BRANCH="$(git -C "$MM_SERVER_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ "$CURRENT_BRANCH" != "$DOCS_CORE_BRANCH" ]]; then + echo "ERROR: core repo at $MM_SERVER_REPO is on '$CURRENT_BRANCH', not '$DOCS_CORE_BRANCH'." >&2 + echo " Check it out first: git -C \"$MM_SERVER_REPO\" checkout $DOCS_CORE_BRANCH" >&2 + exit 1 +fi + +# ── Detect the Docker daemon's architecture ───────────────────────────────────── +# Cross-compile for the arch the *daemon* runs containers as, not the host arch — the two can +# differ (e.g. Docker Desktop on Apple Silicon set to emulate amd64). +DOCKER_ARCH="$(docker info --format '{{.Architecture}}' 2>/dev/null || true)" +case "$DOCKER_ARCH" in + aarch64) GOARCH_TARGET="arm64" ;; + x86_64) GOARCH_TARGET="amd64" ;; + *) + echo "ERROR: could not determine Docker daemon architecture (got '$DOCKER_ARCH'). Is Docker running?" >&2 + exit 1 + ;; +esac +echo "Docker daemon architecture: $DOCKER_ARCH -> GOARCH=$GOARCH_TARGET" + +# ── Cross-compile the server + mmctl binaries ──────────────────────────────────── +# mmctl is required in the image too: testcontainers-mattermost-go drives container setup +# (CreateAdmin, CreateTeam, AddUserToTeam, InstallPlugin, SetConfig) entirely via +# `mmctl --local ...` exec'd inside the container, not the HTTP API. +BINARY="$MM_SERVER_REPO/bin/mattermost-e2e-linux-$GOARCH_TARGET" +MMCTL_BINARY="$MM_SERVER_REPO/bin/mmctl-e2e-linux-$GOARCH_TARGET" +if [[ "$SKIP_BUILD" == true && -f "$BINARY" && -f "$MMCTL_BINARY" ]]; then + echo "Skipping build (--skip-build), reusing $BINARY and $MMCTL_BINARY." +else + echo "Cross-compiling docs-core server + mmctl for linux/$GOARCH_TARGET from branch $DOCS_CORE_BRANCH (this can take a minute)..." + ( cd "$MM_SERVER_REPO" && env GOOS=linux GOARCH="$GOARCH_TARGET" CGO_ENABLED=0 \ + go build -o "$BINARY" ./cmd/mattermost ) + ( cd "$MM_SERVER_REPO" && env GOOS=linux GOARCH="$GOARCH_TARGET" CGO_ENABLED=0 \ + go build -o "$MMCTL_BINARY" ./cmd/mmctl ) + echo "Build complete: $BINARY, $MMCTL_BINARY" +fi + +# ── Assemble the build context ─────────────────────────────────────────────────── +BUILD_CTX="$(mktemp -d)" +trap 'rm -rf "$BUILD_CTX"' EXIT + +MM_ROOT="$BUILD_CTX/mattermost" +mkdir -p "$MM_ROOT/bin" "$MM_ROOT/config" "$MM_ROOT/client/plugins" "$MM_ROOT/plugins" "$MM_ROOT/data" "$MM_ROOT/logs" +cp "$BINARY" "$MM_ROOT/bin/mattermost" +cp "$MMCTL_BINARY" "$MM_ROOT/bin/mmctl" +cp -R "$MM_SERVER_REPO/i18n" "$MM_ROOT/i18n" +cp -R "$MM_SERVER_REPO/templates" "$MM_ROOT/templates" +cp -R "$MM_SERVER_REPO/fonts" "$MM_ROOT/fonts" + +cat > "$BUILD_CTX/Dockerfile" <<'DOCKEREOF' +FROM ubuntu:noble + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + media-types \ + mailcap \ + tzdata \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 2000 mattermost && useradd -u 2000 -g 2000 -m mattermost + +COPY --chown=2000:2000 mattermost /mattermost + +ENV PATH="/mattermost/bin:${PATH}" +ENV MM_SERVICESETTINGS_ENABLELOCALMODE="true" + +USER mattermost +WORKDIR /mattermost + +EXPOSE 8065 +CMD ["mattermost", "server"] +DOCKEREOF + +# ── Build the image ─────────────────────────────────────────────────────────────── +IMAGE_TAG="${CORE_IMAGE:-mm-docs-rbac-core:dev}" +echo "Building Docker image $IMAGE_TAG from $BUILD_CTX ..." +docker build -t "$IMAGE_TAG" "$BUILD_CTX" + +echo "$IMAGE_TAG" diff --git a/server/e2e/README.md b/server/e2e/README.md new file mode 100644 index 0000000..70b15ea --- /dev/null +++ b/server/e2e/README.md @@ -0,0 +1,55 @@ +# Docs plugin — RBAC end-to-end suite + +This is the **official** end-to-end suite for the docs plugin's space-permission RBAC model +(MM-69269). It is Go + [Testcontainers](https://golang.testcontainers.org/), and boots a real +Mattermost server with the plugin installed — no mocks. + +It ports the seven canonical Confluence permission scenarios (plus their named parity gaps) from +`scripts/smoke-scenarios.sh` to run as a proper Go test suite. The bash script stays too, for fast +local iteration; the two are intentionally kept in lockstep rather than merged into one. + +## Why a locally built core image + +The core changes this epic depends on (the `Space` channel type, the atomic per-page capability +roles, the seeded preset schemes) live on an **unmerged** core branch, so no published Mattermost +image carries them yet. `build/build-core-image.sh` cross-compiles `./cmd/mattermost` from that +branch and packages it into a minimal Docker image (`$CORE_IMAGE`, default `mm-docs-rbac-core:dev`). + +Building locally is only needed for a branch that has not been pushed. Core's own CI publishes a +server image for every commit as `mattermostdevelopment/mattermost-team-edition:<7-char-sha>`, so +once the paired core change is up as a PR, `CORE_IMAGE` can point at that tag and no local image +build is required — that is exactly how the `e2e` job in `.github/workflows/ci.yml` runs, reading +the tag from the `CORE_IMAGE` repository variable. Once those core changes merge and ship in a +release, point `CORE_IMAGE` at the released image instead and this suite runs unchanged. + +A bare tag (no `/`) is treated as locally built: if it is absent the suite fails immediately and +names the build script, rather than letting Testcontainers fail mid-boot. A namespaced tag is +assumed pullable and is left to Testcontainers to fetch. + +## Running it + +Requires Docker. + +```sh +make test-e2e +``` + +This builds the core image (`build/build-core-image.sh`), ensures the plugin bundle exists +(`make dist` if missing), then runs: + +```sh +go test -tags e2e -count=1 -v ./server/e2e/... +``` + +The container boots once for the whole suite (container startup is slow) and is torn down after +all tests finish. Every scenario creates its own space and it is deleted at the end of the run. + +## Known environment gap + +Scenario 6 (read-only guest reviewer) drives the real `POST /users/{id}/demote` core endpoint. +That endpoint requires an Enterprise license (`api.team.demote_user_to_guest.license.error`), and +the core image built by `build/build-core-image.sh` has none. In that case the subtest logs the +gap via `t.Log` and returns without asserting the guest-specific behavior — it does not fail the +suite, and it does not fake a pass. Supplying a real EE license to the container (extend +`startEnv` in `container_test.go` with `mmcontainer.WithLicense`) would let this subtest run to +completion. diff --git a/server/e2e/container_test.go b/server/e2e/container_test.go new file mode 100644 index 0000000..262d551 --- /dev/null +++ b/server/e2e/container_test.go @@ -0,0 +1,184 @@ +//go:build e2e + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Package e2e is the official end-to-end suite for the docs plugin's space-permission RBAC model +// (see README.md in this directory). It boots a real Mattermost server (built from the paired core +// branch that carries the space-permission changes) via Testcontainers, installs the plugin into +// it, and drives the seven Confluence permission scenarios plus their named parity gaps through the +// real HTTP API — no mocks. Build with -tags e2e (see `make test-e2e`); it is excluded from +// `go test ./...` and CI's default run by the build tag. +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + mmcontainer "github.com/mattermost/testcontainers-mattermost-go" +) + +// pluginID is the Docs plugin's manifest id (see plugin.json at the repo root). +const pluginID = "com.mattermost.docs" + +// defaultCoreImage matches the CORE_IMAGE default in build/build-core-image.sh — the image it +// produces from the paired core branch until those changes merge and ship in a published release +// image, at which point CORE_IMAGE can point at that instead. Override with the CORE_IMAGE env var. +const defaultCoreImage = "mm-docs-rbac-core:dev" + +// testEnv holds the single Mattermost+plugin container shared by every scenario in this package. +type testEnv struct { + container *mmcontainer.MattermostContainer + baseURL string + adminClient *mmmodel.Client4 +} + +var ( + sharedEnv *testEnv + sharedEnvErr error + sharedEnvOnce sync.Once +) + +// TestMain boots the shared container once for the whole package and tears it down after every +// test has run — container startup is slow (a full Mattermost + Postgres + plugin install), so it +// must not happen per scenario. +func TestMain(m *testing.M) { + code := m.Run() + + if sharedEnv != nil && sharedEnv.container != nil { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + if err := sharedEnv.container.Terminate(ctx); err != nil { + fmt.Printf("failed to terminate shared e2e container: %v\n", err) + } + cancel() + } + + os.Exit(code) +} + +// getEnv returns the shared test environment, starting the container on first use. +func getEnv(t *testing.T) *testEnv { + t.Helper() + sharedEnvOnce.Do(func() { + sharedEnv, sharedEnvErr = startEnv() + }) + if sharedEnvErr != nil { + t.Fatalf("failed to start shared Mattermost+plugin container: %v", sharedEnvErr) + } + return sharedEnv +} + +// startEnv resolves the plugin bundle and the core image, then boots the container. +func startEnv() (*testEnv, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + bundlePath, err := resolveBundlePath() + if err != nil { + return nil, err + } + + imageTag := os.Getenv("CORE_IMAGE") + if imageTag == "" { + imageTag = defaultCoreImage + } + if imgErr := checkImageExists(imageTag); imgErr != nil { + return nil, imgErr + } + + container, err := mmcontainer.RunContainer(ctx, + withImage(imageTag), + mmcontainer.WithPlugin(bundlePath, pluginID, nil), + mmcontainer.WithEnv("MM_FEATUREFLAGS_ENABLEDOCS", "true"), + ) + if err != nil { + return nil, fmt.Errorf("failed to start Mattermost container: %w", err) + } + + baseURL, err := container.URL(ctx) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to resolve container URL: %w", err) + } + + adminClient, err := container.GetAdminClient(ctx) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get admin client: %w", err) + } + + // Core's advanced-permissions phase-2 migration (which seeds the scheme/role state the space + // admin-role assignment in CreateSpace depends on) runs on an async scheduler job, not + // synchronously at boot — mmcontainer's wait strategy only waits for the "Server is listening" + // log line, well before that job's first run. Without this, the very first CreateSpace call + // fails with app.space.create.admin_role_failed.app_error wrapping + // app.schemes.is_phase_2_migration_completed.not_completed.app_error. + if err := waitForPhase2Migration(ctx, adminClient); err != nil { + _ = container.Terminate(ctx) + return nil, err + } + + return &testEnv{container: container, baseURL: baseURL, adminClient: adminClient}, nil +} + +// waitForPhase2Migration polls a Phase2-migration-gated endpoint until it stops reporting +// "not completed", or the deadline passes. +func waitForPhase2Migration(ctx context.Context, adminClient *mmmodel.Client4) error { + deadline := time.Now().Add(2 * time.Minute) + var lastErr error + for time.Now().Before(deadline) { + _, _, err := adminClient.GetSchemes(ctx, "", 0, 1) + if err == nil { + return nil + } + lastErr = err + time.Sleep(2 * time.Second) + } + return fmt.Errorf("advanced-permissions phase-2 migration did not complete within 2 minutes: %w", lastErr) +} + +// resolveBundlePath globs dist/ (relative to this package's directory) for the built plugin +// bundle, failing with a clear pointer to `make dist` when it is absent. +func resolveBundlePath() (string, error) { + matches, err := filepath.Glob("../../dist/" + pluginID + "-*.tar.gz") + if err != nil { + return "", fmt.Errorf("failed to glob plugin bundle: %w", err) + } + if len(matches) == 0 { + return "", fmt.Errorf("no plugin bundle found at dist/%s-*.tar.gz — run `make dist` first", pluginID) + } + return matches[len(matches)-1], nil +} + +// checkImageExists fails clearly (naming the build script) rather than letting Testcontainers +// surface an opaque "no such image" error mid-boot. +// +// A locally built image has a bare tag and can only come from the build script, so its absence is +// a hard error. A namespaced tag (registry/repo:tag) is one CI publishes per core commit, so +// Testcontainers can pull it and a local miss is expected rather than a failure. +func checkImageExists(imageTag string) error { + cmd := exec.Command("docker", "image", "inspect", imageTag) // #nosec -- imageTag is test config (CORE_IMAGE env var), not untrusted input + if err := cmd.Run(); err == nil { + return nil + } + if strings.Contains(imageTag, "/") { + return nil + } + return fmt.Errorf("core image %q not found — build it with ./build/build-core-image.sh (CORE_IMAGE=%s)", imageTag, imageTag) +} + +// withImage overrides the image RunContainer would otherwise use (a stock Mattermost release, +// which does not carry this epic's unmerged core changes) with the locally built core image. +func withImage(img string) mmcontainer.MattermostCustomizeRequestOption { + return func(req *mmcontainer.MattermostContainerRequest) { + req.Image = img + } +} diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go new file mode 100644 index 0000000..22a016f --- /dev/null +++ b/server/e2e/helpers_test.go @@ -0,0 +1,172 @@ +//go:build e2e + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +// pluginAPIBase is the Docs plugin's own HTTP route prefix. It is served directly off the server +// root (/plugins//api/v1/...), not under Client4's /api/v4 prefix, so none of +// Client4's exported Do* helpers (which all prepend APIURL) can address it directly. +const pluginAPIBase = "/plugins/" + pluginID + "/api/v1" + +const actorPassword = "E2e-actor-pass1!" // #nosec G101 -- test-only fixture password, not a credential + +// doPluginRequest issues an HTTP request against the Docs plugin's routes and decodes a non-empty +// JSON response body into out (when non-nil). It wraps Client4's lowest-level exported primitives +// (URL, AuthToken, AuthType, HTTPClient) directly — the same primitives Client4's own +// DoAPIRequestWithHeaders uses internally, minus the APIURL prefix that does not apply here. The +// raw response body is always returned too, so a caller can assert on it directly (e.g. comparing +// two error bodies byte-for-byte). +func doPluginRequest(ctx context.Context, client *mmmodel.Client4, method, path string, body, out any) (int, []byte, error) { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, nil, err + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(client.URL, "/")+pluginAPIBase+path, reader) + if err != nil { + return 0, nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if client.AuthToken != "" { + req.Header.Set("Authorization", client.AuthType+" "+client.AuthToken) + } + + resp, err := client.HTTPClient.Do(req) + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, err + } + if out != nil && len(respBody) > 0 { + _ = json.Unmarshal(respBody, out) + } + return resp.StatusCode, respBody, nil +} + +// actor is a logged-in team member driving requests against the plugin. +type actor struct { + id string + client *mmmodel.Client4 +} + +// createActor creates a real user, adds it to teamID, and logs it in — mirroring +// smoke-scenarios.sh's create_and_join_team + login_actor. +func createActor(t *testing.T, ctx context.Context, env *testEnv, teamID, username string) actor { + t.Helper() + + user := &mmmodel.User{ + Username: username, + Email: username + "@example.com", + Password: actorPassword, + } + created, _, err := env.adminClient.CreateUser(ctx, user) + require.NoError(t, err, "failed to create actor %s", username) + + _, _, err = env.adminClient.AddTeamMember(ctx, teamID, created.Id) + require.NoError(t, err, "failed to add actor %s to team", username) + + client := mmmodel.NewAPIv4Client(env.baseURL) + _, _, err = client.Login(ctx, username, actorPassword) + require.NoError(t, err, "failed to log in actor %s", username) + + return actor{id: created.Id, client: client} +} + +// addSpaceMember adds userID as a plain (default-only) space member via the space admin actor, +// failing loudly if the add did not round-trip with the expected 201. +func addSpaceMember(t *testing.T, ctx context.Context, admin actor, spaceID, userID string) { + t.Helper() + status, body, err := doPluginRequest(ctx, admin.client, http.MethodPost, "/spaces/"+spaceID+"/members", + map[string]string{"user_id": userID}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "add member %s to space %s: %s", userID, spaceID, body) +} + +// spaceMembersResponse is the paginated member-list shape handleListSpaceMembers writes (see +// paginatedResponse in server/api.go) — redeclared here since that type is unexported. +type spaceMembersResponse struct { + Items []*spaceMemberJSON `json:"items"` +} + +// spaceMemberJSON is a minimal decode target for the member-list response; only user_id is needed +// here. +type spaceMemberJSON struct { + UserId string `json:"user_id"` +} + +// spaceHasMember reports whether userID currently appears in spaceID's member list, as resolved +// by the space admin actor — mirroring smoke-scenarios.sh's space_has_member. +func spaceHasMember(t *testing.T, ctx context.Context, admin actor, spaceID, userID string) bool { + t.Helper() + var resp spaceMembersResponse + status, body, err := doPluginRequest(ctx, admin.client, http.MethodGet, "/spaces/"+spaceID+"/members", nil, &resp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "list members of space %s: %s", spaceID, body) + for _, m := range resp.Items { + if m.UserId == userID { + return true + } + } + return false +} + +// deleteSpace deletes spaceID via the space admin actor. Registered via t.Cleanup by every +// scenario that creates a space. +func deleteSpace(t *testing.T, ctx context.Context, admin actor, spaceID string) { + t.Helper() + status, body, err := doPluginRequest(ctx, admin.client, http.MethodDelete, "/spaces/"+spaceID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "cleanup: delete space %s: %s", spaceID, body) +} + +// pageDocBody is a minimal valid page body (see model.Page.IsValid — only size-limited, not +// schema-validated), matching smoke-scenarios.sh's create_page_body/edit_page_body. +const pageDocBody = `{"type":"doc","content":[]}` + +// createPageReq builds a minimal valid page-create body. +func createPageReq(title string) map[string]any { + return map[string]any{"title": title, "body": pageDocBody} +} + +// editPageReq builds a minimal valid page-update body; body and search_text must both be present +// together (see PagePatch.IsValid's cross-check). +func editPageReq(baseEditAt int64, searchText string) map[string]any { + return map[string]any{"base_edit_at": baseEditAt, "body": pageDocBody, "search_text": searchText} +} + +// appErrorID extracts the AppError id from a plugin JSON error body, for asserting on the specific +// error rather than just the status code. +func appErrorID(body []byte) string { + var er struct { + Id string `json:"id"` + } + if err := json.Unmarshal(body, &er); err != nil { + return "" + } + return er.Id +} diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go new file mode 100644 index 0000000..ab4fdd4 --- /dev/null +++ b/server/e2e/scenarios_test.go @@ -0,0 +1,487 @@ +//go:build e2e + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package e2e + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + pluginmodel "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// TestScenarios ports the seven canonical Confluence space-permission scenarios plus the named +// parity gaps (MM-69269) from scripts/smoke-scenarios.sh to run against a real Mattermost server +// (built from the paired core branch) with the plugin installed, via Testcontainers. See +// scripts/smoke-scenarios.sh for the authoritative behavioral spec this ports; the two suites are +// intentionally kept in lockstep, not merged. +func TestScenarios(t *testing.T) { + env := getEnv(t) + ctx := context.Background() + + teamName := fmt.Sprintf("e2e-scenario-%d", time.Now().UnixNano()) + team, _, err := env.adminClient.CreateTeam(ctx, &mmmodel.Team{ + Name: teamName, + DisplayName: "E2E Scenario Smoke", + Type: mmmodel.TeamOpen, + }) + require.NoError(t, err, "failed to create scenario team") + + ts := time.Now().UnixNano() + spaceAdmin := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-admin-%d", ts)) + contrib := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-contrib-%d", ts)) + member := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-member-%d", ts)) + outsider := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-outsider-%d", ts)) + guestCandidate := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-guest-%d", ts)) + + // spacesToClean accumulates every space id created below; deleted once at the very end (after + // the gap subtests, which continue off scenario 1's space) rather than per-subtest, since a + // t.Cleanup registered on a subtest's own *testing.T fires when that subtest finishes — too + // early for a space a later gap subtest still needs. + var spacesToClean []string + t.Cleanup(func() { + for _, id := range spacesToClean { + deleteSpace(t, ctx, spaceAdmin, id) + } + }) + + var s1ID string // captured for the gap subtests, which continue off scenario 1's space. + + t.Run("scenario1_open_wiki", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Open Wiki"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + require.Regexp(t, "^[a-z0-9]{26}$", space.Id) + require.Equal(t, pluginmodel.ViewAccessOpen, space.ViewAccess, "expected create-time default view_access=open") + s1ID = space.Id + spacesToClean = append(spacesToClean, s1ID) + + addSpaceMember(t, ctx, spaceAdmin, s1ID, member.id) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+s1ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "existing member read: %s", body) + + var page pluginmodel.Page + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+s1ID+"/pages", + createPageReq("Open Wiki Page"), &page) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "member create page failed: %s", body) + require.Regexp(t, "^[a-z0-9]{26}$", page.Id) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s1ID+"/pages/"+page.Id, + editPageReq(page.EditAt, "edited by an existing member"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "existing member edit: %s", body) + + // Headline assertion: a team non-member's first default-granted write auto-joins them. + status, body, err = doPluginRequest(ctx, outsider.client, http.MethodPost, "/spaces/"+s1ID+"/pages", + createPageReq("Drive-by page"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "non-member create: %s", body) + require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "non-member's drive-by write did not auto-join them (real GetChannelMember/AddChannelMember round-trip)") + }) + + t.Run("scenario2_knowledge_base", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Knowledge Base"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + + var roResp pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/default-capabilities", + map[string][]string{"default_capabilities": {}}, &roResp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "setting read-only default failed: %s", body) + require.Empty(t, roResp.DefaultCapabilities) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("KB Seed Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "MEMBER read") + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Unauthorized"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "ungranted MEMBER create: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "nope"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "ungranted MEMBER edit: %s", body) + + // The real grant surface: the space admin assigns create_page + edit_page to CONTRIB. + var grantResp pluginmodel.SpaceMember + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage}}, &grantResp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting CONTRIB capabilities: %s", body) + require.ElementsMatch(t, []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage}, grantResp.GrantedCapabilities, + "grant round-tripped as %v", grantResp.GrantedCapabilities) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Contributed"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "granted CONTRIB create: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "edited by contributor"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granted CONTRIB edit: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Still unauthorized"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "MEMBER still expected 403 after CONTRIB's grant: %s", body) + }) + + t.Run("scenario3_private_team_space", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Private Team Space", "view_access": pluginmodel.ViewAccessPrivate}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + // OUTSIDER is deliberately NOT added — it is the non-member control for this scenario. + + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "member read") + + var strangerBody map[string]any + status, strangerRaw, err := doPluginRequest(ctx, outsider.client, http.MethodGet, "/spaces/"+space.Id, nil, &strangerBody) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "stranger metadata read: %s", strangerRaw) + + status, _, err = doPluginRequest(ctx, outsider.client, http.MethodGet, "/spaces/"+space.Id+"/pages", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "stranger page-list read") + + // The identical 403 for a syntactically valid but nonexistent space id — no existence oracle. + nonexistentID := reverseString(space.Id) + var nonexistentBody map[string]any + status, nonexistentRaw, err := doPluginRequest(ctx, outsider.client, http.MethodGet, "/spaces/"+nonexistentID, nil, &nonexistentBody) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "nonexistent-space read: %s", nonexistentRaw) + require.Equal(t, strangerBody, nonexistentBody, + "a real private space's 403 body differs from a nonexistent id's 403 body (existence oracle)") + }) + + t.Run("scenario4_announcement_space", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Announcement Space", "default_capabilities": []string{pluginmodel.CapabilityCommentPage}}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + // The create response is the bare Space (no default_capabilities field); GET the space to + // confirm the comment default actually landed. + var withAccess pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "get space: %s", body) + require.ElementsMatch(t, []string{pluginmodel.CapabilityCommentPage}, withAccess.DefaultCapabilities, + "comment default not set at create: %s", body) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "plain member read") + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Unauthorized announcement"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "plain member create (comment default grants no create_page): %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting CONTRIB create_page: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Announcement"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "granted author create: %s", body) + + t.Log("scenario4: the comment halves (a plain member commenting, a granted commenter succeeding) are not asserted — the comments epic has not landed its routes yet, so there is nothing to drive them through.") + }) + + t.Run("scenario5_mixed_matrix", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Mixed Matrix", "default_capabilities": []string{}}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Matrix Seed Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting A create_page: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+member.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting B edit_page: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("By A"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "A (create_page-only) create: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "a"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "A (create_page-only) edit: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "b"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "B (edit_page-only) edit: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("By B"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "B (edit_page-only) create: %s", body) + }) + + t.Run("scenario6_readonly_guest_reviewer", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Guest Reviewer"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, guestCandidate.id) + + cfg, _, err := env.adminClient.GetConfig(ctx) + require.NoError(t, err, "failed to read server config") + originalGuestEnable := cfg.GuestAccountsSettings.Enable != nil && *cfg.GuestAccountsSettings.Enable + + demoteResp, demoteErr := env.adminClient.DemoteUserToGuest(ctx, guestCandidate.id) + if demoteErr != nil { + var appErr *mmmodel.AppError + if !errors.As(demoteErr, &appErr) { + t.Fatalf("scenario6: demote failed with a non-AppError: %v", demoteErr) + } + if strings.Contains(appErr.Id, "license") { + t.Logf("scenario6: guest reviewer flow (real DemoteUserToGuest) is not testable in this environment — %s: %s. "+ + "The core image built by build/build-core-image.sh has no Enterprise license, and guest accounts require one. "+ + "Not asserted here; see README.md.", appErr.Id, appErr.Error()) + return + } + if !strings.Contains(appErr.Id, "disabled") { + t.Fatalf("scenario6: demote failed unexpectedly: %s (%s)", appErr.Id, appErr.Error()) + } + + t.Logf("scenario6: guest accounts disabled (%s) — enabling GuestAccountsSettings.Enable for this run", appErr.Id) + _, _, patchErr := env.adminClient.PatchConfig(ctx, &mmmodel.Config{GuestAccountsSettings: mmmodel.GuestAccountsSettings{Enable: mmmodel.NewPointer(true)}}) + require.NoError(t, patchErr, "enabling GuestAccountsSettings failed") + t.Cleanup(func() { + _, _, restoreErr := env.adminClient.PatchConfig(ctx, &mmmodel.Config{GuestAccountsSettings: mmmodel.GuestAccountsSettings{Enable: mmmodel.NewPointer(originalGuestEnable)}}) + if restoreErr != nil { + t.Logf("scenario6: failed to restore GuestAccountsSettings.Enable=%v: %v", originalGuestEnable, restoreErr) + } + }) + + demoteResp, demoteErr = env.adminClient.DemoteUserToGuest(ctx, guestCandidate.id) + require.NoError(t, demoteErr, "demote still failed after enabling guest accounts") + } + require.Equal(t, http.StatusOK, demoteResp.StatusCode, "demote expected 200") + + status, body, err = doPluginRequest(ctx, guestCandidate.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "guest read: %s", body) + + status, body, err = doPluginRequest(ctx, guestCandidate.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Not allowed"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "guest create: %s", body) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Guest Seed Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, body, err = doPluginRequest(ctx, guestCandidate.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "guest edit"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "guest update: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+guestCandidate.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, status, "granting a guest capabilities: %s", body) + require.Equal(t, "app.space.member.guest_not_assignable.app_error", appErrorID(body)) + }) + + var s7ID string + t.Run("scenario7_delegated_space_admin", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Delegated Admin"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + s7ID = space.Id + spacesToClean = append(spacesToClean, s7ID) + + addSpaceMember(t, ctx, spaceAdmin, s7ID, member.id) // the plain-member control actor for this scenario + updateAt := space.UpdateAt + + status, _, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+s7ID+"/members", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin list members") + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+s7ID+"/members", + map[string]string{"user_id": contrib.id}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin add member: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin set member capabilities: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID+"/default-capabilities", + map[string][]string{"default_capabilities": {pluginmodel.CapabilityCommentPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin set default capabilities: %s", body) + + var patched pluginmodel.Space + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID, + map[string]any{"view_access": pluginmodel.ViewAccessPrivate, "expected_update_at": updateAt}, &patched) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin ViewAccess flip failed: %s", body) + require.Equal(t, pluginmodel.ViewAccessPrivate, patched.ViewAccess) + updateAt = patched.UpdateAt + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodDelete, "/spaces/"+s7ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin delete: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID+"/restore", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin restore: %s", body) + + // A plain member (no elevation) is denied on each of the same routes. + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+s7ID+"/members", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control list members") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+s7ID+"/members", + map[string]string{"user_id": outsider.id}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control add member") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control set member capabilities") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s7ID+"/default-capabilities", + map[string][]string{"default_capabilities": {pluginmodel.CapabilityCommentPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control set default capabilities") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s7ID, + map[string]any{"view_access": pluginmodel.ViewAccessOpen, "expected_update_at": updateAt}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control ViewAccess flip") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodDelete, "/spaces/"+s7ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control delete") + }) + + t.Run("gap_open_space_removal_not_durable", func(t *testing.T) { + // Continues from scenario 1's open space, where OUTSIDER is already a member via auto-join. + require.NotEmpty(t, s1ID, "scenario1 must have run first") + require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "expected OUTSIDER to already be a member of %s from scenario1", s1ID) + + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodDelete, "/spaces/"+s1ID+"/members/"+outsider.id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "removing OUTSIDER: %s", body) + require.False(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "OUTSIDER still listed as a member immediately after removal") + + status, body, err = doPluginRequest(ctx, outsider.client, http.MethodPost, "/spaces/"+s1ID+"/pages", + createPageReq("Regained access"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "OUTSIDER's post-removal write: %s", body) + require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "OUTSIDER's default-granted write did not re-join them — removal should NOT be durable on an open space") + }) + + t.Run("gap_delete_page_not_grantable", func(t *testing.T) { + require.NotEmpty(t, s1ID, "scenario1 must have run first") + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s1ID+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityDeletePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, status, "granting delete_page: %s", body) + }) + + t.Run("gap_anonymous_access_denied_on_open_space", func(t *testing.T) { + require.NotEmpty(t, s1ID, "scenario1 must have run first") + anon := mmmodel.NewAPIv4Client(env.baseURL) + status, body, err := doPluginRequest(ctx, anon, http.MethodGet, "/spaces/"+s1ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, status, + "an unauthenticated read on an open space: %s", body) + }) + + // The two remaining named parity gaps have no API surface to probe: per-group capability + // grants beyond member/admin (a synced group's GroupSyncable carries only a binary + // SchemeAdmin, so an arbitrary per-group capability set has no route to request it), and the + // export_space split (Export is mapped onto admin_space in this epic; no separate export + // capability exists to grant, deny, or probe). +} + +// reverseString reverses s, mirroring smoke-scenarios.sh's `echo "$ID" | rev` — used to derive a +// syntactically valid but nonexistent id from a real one. +func reverseString(s string) string { + r := []rune(s) + for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { + r[i], r[j] = r[j], r[i] + } + return string(r) +} From 8ac2c0b09f0742384d6782594d4e030429ecd125 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 18:44:24 +0200 Subject: [PATCH 10/28] sddress coderabbitai comments --- build/build-core-image.sh | 6 ++-- go.mod | 5 ++-- go.sum | 16 ++++++----- server/api_handler_test.go | 4 ++- server/app/page_hierarchy.go | 15 +++++++++- server/app/page_move_to_space_test.go | 30 ++++++++++++++++++++ server/app/permissions.go | 8 +++++- server/app/space.go | 3 ++ server/e2e/container_test.go | 40 +++++++++++++++++++++++---- server/e2e/helpers_test.go | 5 +++- server/store/space_store.go | 8 ++++-- 11 files changed, 115 insertions(+), 25 deletions(-) diff --git a/build/build-core-image.sh b/build/build-core-image.sh index ae6c366..026f5ef 100755 --- a/build/build-core-image.sh +++ b/build/build-core-image.sh @@ -12,9 +12,9 @@ # 4. Builds the image and tags it $CORE_IMAGE (default mm-docs-rbac-core:dev). # # Usage: -# ./scripts/build-core-image.sh -# CORE_IMAGE=my-tag:dev ./scripts/build-core-image.sh -# ./scripts/build-core-image.sh --skip-build # reuse the existing binary at $MM_SERVER_REPO/bin/mattermost +# ./build/build-core-image.sh +# CORE_IMAGE=my-tag:dev ./build/build-core-image.sh +# ./build/build-core-image.sh --skip-build # reuse the existing binary at $MM_SERVER_REPO/bin/mattermost # # Requires: # - Docker running locally. diff --git a/go.mod b/go.mod index 93cc973..5f01bf3 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/beevik/etree v1.6.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/containerd v1.7.18 // indirect + github.com/containerd/containerd v1.7.33 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -79,7 +79,8 @@ require ( github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect - github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 13d0131..d105a18 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,8 @@ github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= -github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= +github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M= +github.com/containerd/containerd v1.7.33/go.mod h1:gSbSCVjPCdkfJCjyrzz7aRC+xFlqVbatNpfHfVCYGUM= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -229,8 +229,10 @@ github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkV github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -451,8 +453,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -481,7 +483,7 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0= +google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 9dc77d3..45ecc1d 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -929,7 +929,9 @@ func TestHandler_MovePageToSpace_RejectsCycle(t *testing.T) { user := mmmodel.NewId() channelA := mmmodel.NewId() spaceA := seedSpace(t, h.store, h.db, channelA) - root := seedPage(t, h.store, spaceA.Id, channelA, "") + // Owned by the acting user: a contribute-default member holds only delete_own_page, so the + // same-space move requires ownership of the reparented page before the cycle check is reached. + root := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") child := seedPage(t, h.store, spaceA.Id, channelA, root.Id) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+root.Id+"/move-to-space", user, map[string]any{ diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 90ce878..f9a269d 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -165,7 +165,8 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID // move does not change the page's LastModifiedBy. requiredOwnerID, when non-empty, requires // every live page in the moved subtree to be owned by it — the gate resolves this to userID on // the delete_own_page-only path and "" on the delete_page (any) path; a mismatch fails the move -// in-transaction. Per-page restrictions and redirects are not handled yet. +// in-transaction. A same-space request requires only the reparented root to be owned, since no +// other page leaves the space. Per-page restrictions and redirects are not handled yet. func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID, requiredOwnerID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -214,6 +215,18 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model // requestedParent is passed explicitly because nil means "target root" here but "leave // unchanged" to MovePage. if sourceSpace.Id == targetSpace.Id { + // The subtree keeps its space, so only the reparented root is affected — an own-only caller + // must own it. A page's UserId never changes after creation, so reading it before the store's + // locked reparent carries no check-then-act window. + if requiredOwnerID != "" { + page, pageErr := s.GetPageInSpace("MovePageToSpace", pageID, sourceSpace.Id, false) + if pageErr != nil { + return nil, pageErr + } + if page.UserId != requiredOwnerID { + return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusBadRequest) + } + } if requestedParent != "" && requestedParent != curParentID { if destErr := s.validateDestinationParent("MovePageToSpace", pageID, requestedParent, sourceSpace.Id); destErr != nil { return nil, destErr diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index 2efaad0..f743d63 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -362,6 +362,36 @@ func TestServiceMovePageToSpace_SameSpaceReparent(t *testing.T) { mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_moved_to_space", mock.Anything, mock.Anything) } +// TestServiceMovePageToSpace_SameSpaceRequiredOwnerID verifies the own-scoped gate holds on the +// same-space path too: a caller resolved to delete_own_page can reparent a page it owns, but not +// one owned by someone else — the branch delegates to the in-space move, which carries no +// ownership check of its own. +func TestServiceMovePageToSpace_SameSpaceRequiredOwnerID(t *testing.T) { + h := openTestService(t) + teamID := mmmodel.NewId() + owner := mmmodel.NewId() + ch := mmmodel.NewId() + space := seedSpaceForTeam(t, h.store, h.db, ch, teamID) + + newParent := mustCreatePage(t, h.store, space.Id, ch, owner, "") + parentID := newParent.Id + + foreign := mustCreatePage(t, h.store, space.Id, ch, mmmodel.NewId(), "") + _, appErr := h.svc.MovePageToSpace(foreign.Id, space, space, &parentID, new(foreign.UpdateAt), false, owner, owner) + require.NotNil(t, appErr) + require.Equal(t, 400, appErr.StatusCode) + require.Equal(t, "app.page.move_to_space.subtree_not_owned.app_error", appErr.Id) + + stillRoot, getErr := h.svc.GetPage(foreign.Id) + require.Nil(t, getErr) + require.Empty(t, stillRoot.ParentId) + + own := mustCreatePage(t, h.store, space.Id, ch, owner, "") + moved, appErr := h.svc.MovePageToSpace(own.Id, space, space, &parentID, new(own.UpdateAt), false, owner, owner) + require.Nil(t, appErr) + require.Equal(t, newParent.Id, moved.ParentId) +} + // TestServiceMovePageToSpace_NoOpRejectsStaleSource verifies a stale sourceSpaceID is rejected as // not-found even when the requested target space and parent already match the page's current // location: after a concurrent move landed the page in targetSpaceID, a caller still addressing diff --git a/server/app/permissions.go b/server/app/permissions.go index 146f7e3..b286b82 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -260,6 +260,7 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad } joined := false + var joinedUserID, joinedChannelID string lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { fresh, getErr := s.store.GetSpace(space.Id, false) if getErr != nil { @@ -299,7 +300,7 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad return addErr } joined = true - s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": fresh.Id, "user_id": member.UserId}, fresh.ChannelId) + joinedUserID, joinedChannelID = member.UserId, fresh.ChannelId return nil }) if lockErr != nil { @@ -309,5 +310,10 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad } return false, storeAppError("AutoJoinIfDefaultGranted", lockErr) } + // Published after the lock is released: the membership lock holds a dedicated connection, so a + // slow publish inside it would push concurrent membership mutations into a lock timeout. + if joined { + s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": joinedUserID}, joinedChannelID) + } return joined, nil } diff --git a/server/app/space.go b/server/app/space.go index 57c7c10..5b45a88 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -370,6 +370,9 @@ func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) // capability set plus the caller's own truthful, current effective capabilities — never a // hypothetical post-join grant. A denied read yields the shared existence-hiding 403. func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*model.SpaceWithAccess, *mmmodel.AppError) { + if space == nil { + return nil, mmmodel.NewAppError("BuildSpaceWithAccess", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } if appErr := s.requireClient("BuildSpaceWithAccess", "space_id", space.Id, "user_id", userID); appErr != nil { return nil, appErr } diff --git a/server/e2e/container_test.go b/server/e2e/container_test.go index 262d551..5c82aba 100644 --- a/server/e2e/container_test.go +++ b/server/e2e/container_test.go @@ -103,15 +103,24 @@ func startEnv() (*testEnv, error) { return nil, fmt.Errorf("failed to start Mattermost container: %w", err) } + // Teardown runs on its own context: the likeliest failure below is ctx's own deadline expiring, + // and Terminate cannot clean up on an already-expired context — leaving a Mattermost+Postgres + // pair running on the CI agent or dev machine. + terminate := func() { + termCtx, termCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer termCancel() + _ = container.Terminate(termCtx) + } + baseURL, err := container.URL(ctx) if err != nil { - _ = container.Terminate(ctx) + terminate() return nil, fmt.Errorf("failed to resolve container URL: %w", err) } adminClient, err := container.GetAdminClient(ctx) if err != nil { - _ = container.Terminate(ctx) + terminate() return nil, fmt.Errorf("failed to get admin client: %w", err) } @@ -122,7 +131,7 @@ func startEnv() (*testEnv, error) { // fails with app.space.create.admin_role_failed.app_error wrapping // app.schemes.is_phase_2_migration_completed.not_completed.app_error. if err := waitForPhase2Migration(ctx, adminClient); err != nil { - _ = container.Terminate(ctx) + terminate() return nil, err } @@ -140,13 +149,19 @@ func waitForPhase2Migration(ctx context.Context, adminClient *mmmodel.Client4) e return nil } lastErr = err - time.Sleep(2 * time.Second) + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for advanced-permissions phase-2 migration: %w (last error: %v)", ctx.Err(), lastErr) + case <-time.After(2 * time.Second): + } } return fmt.Errorf("advanced-permissions phase-2 migration did not complete within 2 minutes: %w", lastErr) } // resolveBundlePath globs dist/ (relative to this package's directory) for the built plugin -// bundle, failing with a clear pointer to `make dist` when it is absent. +// bundle, failing with a clear pointer to `make dist` when it is absent. Several bundles can sit +// there (a version bump, or a stale host-only bundle from `make server`), so the newest by +// modification time wins — the same one `make test-e2e` inspects before deciding to rebuild. func resolveBundlePath() (string, error) { matches, err := filepath.Glob("../../dist/" + pluginID + "-*.tar.gz") if err != nil { @@ -155,7 +170,20 @@ func resolveBundlePath() (string, error) { if len(matches) == 0 { return "", fmt.Errorf("no plugin bundle found at dist/%s-*.tar.gz — run `make dist` first", pluginID) } - return matches[len(matches)-1], nil + newest, newestMod := "", time.Time{} + for _, match := range matches { + info, statErr := os.Stat(match) + if statErr != nil { + continue + } + if newest == "" || info.ModTime().After(newestMod) { + newest, newestMod = match, info.ModTime() + } + } + if newest == "" { + return "", fmt.Errorf("no readable plugin bundle at dist/%s-*.tar.gz", pluginID) + } + return newest, nil } // checkImageExists fails clearly (naming the build script) rather than letting Testcontainers diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go index 22a016f..8742bc5 100644 --- a/server/e2e/helpers_test.go +++ b/server/e2e/helpers_test.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "strings" @@ -63,7 +64,9 @@ func doPluginRequest(ctx context.Context, client *mmmodel.Client4, method, path return resp.StatusCode, nil, err } if out != nil && len(respBody) > 0 { - _ = json.Unmarshal(respBody, out) + if err := json.Unmarshal(respBody, out); err != nil { + return resp.StatusCode, respBody, fmt.Errorf("failed to decode response body %q: %w", respBody, err) + } } return resp.StatusCode, respBody, nil } diff --git a/server/store/space_store.go b/server/store/space_store.go index 478ca18..86fe503 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -101,14 +101,16 @@ func (s *Store) GetSpacesForTeam(teamID, userID string, callerHasOpenFallthrough return nil, err } - memberExists := sq.Expr("EXISTS (SELECT 1 FROM ChannelMembers cm WHERE cm.ChannelId = sp.ChannelId AND cm.UserId = ?)", userID) - openFallthrough := sq.And{sq.Eq{"sp.ViewAccess": model.ViewAccessOpen}, sq.Expr("?", callerHasOpenFallthrough)} + visible := sq.Or{sq.Expr("EXISTS (SELECT 1 FROM ChannelMembers cm WHERE cm.ChannelId = sp.ChannelId AND cm.UserId = ?)", userID)} + if callerHasOpenFallthrough { + visible = append(visible, sq.Eq{"sp.ViewAccess": model.ViewAccessOpen}) + } builder := s.getQueryBuilder(). Select(columnsWithAlias("sp", spaceSelectColumns)...). From("DOCS_Space sp"). Where(sq.Eq{"sp.TeamId": teamID, "sp.DeleteAt": 0}). - Where(sq.Or{memberExists, openFallthrough}). + Where(visible). OrderBy("sp.SortOrder ASC", "sp.CreateAt DESC", "sp.Id ASC") builder = applyLimitOffset(builder, offset, limit) From 661afdfc45acebfccc789da6b0e7b811aee2a238 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 27 Jul 2026 23:48:33 +0200 Subject: [PATCH 11/28] simplification, comments updates --- build/build-core-image.sh | 2 +- server/api.go | 51 +++++------------------- server/api_handler_test.go | 11 +++--- server/api_page.go | 25 ++++++------ server/api_space.go | 7 ++-- server/app/page_hierarchy.go | 17 +++++--- server/app/permissions.go | 59 ++++++++++++++++++++++------ server/app/space.go | 43 ++++++++++++++------ server/app/space_members.go | 28 ++----------- server/app/ws_events.go | 2 +- server/e2e/helpers_test.go | 4 +- server/e2e/scenarios_test.go | 10 ++--- server/internal/testutil/fixtures.go | 5 ++- server/model/space_capabilities.go | 43 +++++++++----------- server/store/page_move.go | 7 ++-- server/store/scheme_store.go | 5 +-- server/store/scheme_store_test.go | 6 +-- server/store/space_store.go | 5 +-- 18 files changed, 162 insertions(+), 168 deletions(-) diff --git a/build/build-core-image.sh b/build/build-core-image.sh index 026f5ef..d58e439 100755 --- a/build/build-core-image.sh +++ b/build/build-core-image.sh @@ -14,7 +14,7 @@ # Usage: # ./build/build-core-image.sh # CORE_IMAGE=my-tag:dev ./build/build-core-image.sh -# ./build/build-core-image.sh --skip-build # reuse the existing binary at $MM_SERVER_REPO/bin/mattermost +# ./build/build-core-image.sh --skip-build # reuse the existing binaries at $MM_SERVER_REPO/bin/{mattermost,mmctl}-e2e-linux- # # Requires: # - Docker running locally. diff --git a/server/api.go b/server/api.go index 3be4de1..07638e3 100644 --- a/server/api.go +++ b/server/api.go @@ -25,9 +25,8 @@ import ( // Every space- and page-scoped handler additionally gates on the capability-based RBAC model: // requireSpaceRead/requireSpacePagePerm for reads, gatePageWrite/gateDeleteOwnOrAny for page // writes (with the open-space auto-join pre-step), requireSpaceManageGate for membership -// management and general space-field updates (where a patch touching ViewAccess is additionally -// admin-gated inside UpdateSpace, against the live row), and requireSpaceAdminGate/ -// requireSpaceDeleteGate for the space-wide exposure-policy and delete/restore operations. See +// management and general space-field updates, and requireSpaceAdminGate/requireSpaceDeleteGate +// for the space-wide exposure-policy and delete/restore operations. See // server/app/permissions.go for the gate implementations. func (p *Plugin) initRouter() *mux.Router { router := mux.NewRouter() @@ -181,14 +180,13 @@ func (p *Plugin) requireSpaceDeleteGate(w http.ResponseWriter, spaceID, userID s // gatePageWrite resolves the read gate first — a caller cannot be granted write authority over a // space it cannot read — then runs the auto-join pre-step when that read was admitted only via the // non-member open-space fall-through, then re-resolves perm as a (possibly just-joined) member. -// ownerCheck, when non-nil, additionally must hold before a join happens (used for -// delete_own_page). Writes the error response and returns false on any denial. -func (p *Plugin) gatePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, ownerCheck func() (bool, error)) bool { +// Writes the error response and returns false on any denial. +func (p *Plugin) gatePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission) bool { resolution, ok := p.resolveSpaceReadOrDeny(w, "gatePageWrite", space, userID) if !ok { return false } - if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, ownerCheck); appErr != nil { + if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, nil); appErr != nil { p.writeAppError(w, appErr) return false } @@ -201,17 +199,14 @@ func (p *Plugin) gatePageWrite(w http.ResponseWriter, space *model.Space, userID // gateDeleteOwnOrAny gates a delete-class page operation: delete_page (any), or delete_own_page // when ownerID == userID. The auto-join pre-step runs against delete_own_page, gated on -// ownership, since only that path can admit a non-member write. -func (p *Plugin) gateDeleteOwnOrAny(w http.ResponseWriter, space *model.Space, userID, ownerID string) bool { - resolution, ok := p.resolveSpaceReadOrDeny(w, "gateDeleteOwnOrAny", space, userID) - if !ok { - return false - } +// ownership, since only that path can admit a non-member write. resolution is the read gate the +// caller has already resolved for the same space and user, so it is not re-derived here. +func (p *Plugin) gateDeleteOwnOrAny(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) bool { if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }); appErr != nil { p.writeAppError(w, appErr) return false } - _, ok, permErr := p.resolveOwnOrAny(space, userID, "api.page.delete", mmmodel.PermissionDeletePage, "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) + _, ok, permErr := p.service.ResolveSpacePageOwnOrAny(space, userID, "api.page.delete", mmmodel.PermissionDeletePage, "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) if permErr != nil { p.writeAppError(w, permErr) return false @@ -239,34 +234,6 @@ func (p *Plugin) resolveSpaceReadOrDeny(w http.ResponseWriter, where string, spa return resolution, true } -// resolveOwnOrAny evaluates a two-tier own/any permission pair: anyPerm if held, else ownPerm -// when ownerMatches. Reports whether the caller qualified only via ownPerm (ownOnly), so a caller -// that must push ownership enforcement further down — MovePageToSpace's subtree-wide check — can -// tell the two tiers apart. ok=false with a nil appErr means neither tier admitted the caller; the -// caller writes its own denial so the operation label stays its own. A non-nil appErr is a genuine -// backend failure from the check itself, which the caller must surface as-is rather than reporting -// as a denial. -func (p *Plugin) resolveOwnOrAny(space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, admittedVia app.ReadResolution) (ownOnly, ok bool, appErr *mmmodel.AppError) { - anyErr := p.service.RequireSpacePagePermissionFrom(anyWhere, space, userID, anyPerm, admittedVia) - if anyErr == nil { - return false, true, nil - } - if anyErr.StatusCode != http.StatusForbidden { - return false, false, anyErr - } - if !ownerMatches { - return false, false, nil - } - ownErr := p.service.RequireSpacePagePermissionFrom(ownWhere, space, userID, ownPerm, admittedVia) - if ownErr == nil { - return true, true, nil - } - if ownErr.StatusCode != http.StatusForbidden { - return false, false, ownErr - } - return false, false, nil -} - // EnableDocsRequired is a middleware that rejects all API requests with 501 Not Implemented when // the EnableDocs feature flag is off. func (p *Plugin) EnableDocsRequired(next http.Handler) http.Handler { diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 45ecc1d..2cf78e1 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -311,8 +311,8 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { }) } -// TestHandler_UpdateSpace patches a space's mutable fields. Updating a space is manage-gated -// : the acting user needs an elevated grant, not bare membership. +// TestHandler_UpdateSpace patches a space's mutable fields. Updating a space is manage-gated: the +// acting user needs an elevated grant, not bare membership. func TestHandler_UpdateSpace(t *testing.T) { mockAPI := newEnabledMockAPI() adminID := mmmodel.NewId() @@ -541,8 +541,8 @@ func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { require.True(t, resp.HasMore) } -// TestHandler_AddSpaceMember adds a member to a space; the caller needs requireSpaceManage — no -// longer any current space member. +// TestHandler_AddSpaceMember adds a member to a space; the caller needs requireSpaceManage +// authority. func TestHandler_AddSpaceMember(t *testing.T) { channelID := mmmodel.NewId() targetUserID := mmmodel.NewId() @@ -2026,8 +2026,9 @@ func TestHandler_SetSpaceMemberCapabilities_PublishesEvent(t *testing.T) { // the isolated test DB's Channels stand-in row, keeping subsequent store-side scheme resolution // (GetSchemeRolesForChannel, spaceDefaultCapabilities — which read the DB row directly, not the // mocked channel) consistent with the mocked channel across repeated calls in the same test. +// // Must be registered before openTestPlugin (GetChannelOfType's default catch-all would otherwise -// shadow it — the same registration-order rule as grantSpaceManage/grantSpaceAdmin); hp is +// shadow it — the same registration-order rule as grantSpaceManage/grantSpaceAdmin). hp is // assigned by the caller once openTestPlugin returns, and the returned *model.Channel lets the // caller seed a specific starting scheme id once the harness (and its seeded preset scheme ids) // exists. diff --git a/server/api_page.go b/server/api_page.go index 8f9cfc8..739ec0f 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -45,7 +45,7 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.gatePageWrite(w, space, userID, mmmodel.PermissionCreatePage, nil) { + if !p.gatePageWrite(w, space, userID, mmmodel.PermissionCreatePage) { return } @@ -90,7 +90,7 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage, nil) { + if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage) { return } @@ -120,7 +120,7 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - space, _, ok := p.requireSpaceRead(w, vars["space_id"], userID) + space, read, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -129,7 +129,7 @@ func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId) { + if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId, read) { return } if appErr := p.service.DeletePage(vars["page_id"], vars["space_id"], userID); appErr != nil { @@ -144,7 +144,7 @@ func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - space, _, ok := p.requireSpaceRead(w, vars["space_id"], userID) + space, read, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -153,7 +153,7 @@ func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId) { + if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId, read) { return } restored, appErr := p.service.RestorePage(vars["page_id"], vars["space_id"], userID) @@ -176,7 +176,7 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage, nil) { + if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage) { return } @@ -228,7 +228,7 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { return } } - if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage, nil) { + if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage) { return } @@ -293,12 +293,13 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { return } } - // Source-side remove-class gate: delete_page (any) if held, else delete_own_page (the store - // enforces subtree-wide ownership in-transaction via requiredOwnerID). No source auto-join — + // Source-side remove-class gate: delete_page (any) if held, else delete_own_page, which + // requires the caller own every page in the moved subtree (passed down as requiredOwnerID). + // No source auto-join — // only the target side admits a non-member write. Resolved before the target gate below, which // can join the caller to the target space: a caller denied here must not be left holding a // membership the rejected request created. - ownOnly, allowed, permErr := p.resolveOwnOrAny(sourceSpace, userID, + ownOnly, allowed, permErr := p.service.ResolveSpacePageOwnOrAny(sourceSpace, userID, "api.page.move_to_space", mmmodel.PermissionDeletePage, "api.page.move_to_space.own", mmmodel.PermissionDeleteOwnPage, true, sourceRead) if permErr != nil { @@ -314,7 +315,7 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { requiredOwnerID = userID } - if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage, nil) { + if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage) { return } diff --git a/server/api_space.go b/server/api_space.go index 437f955..bde6edd 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -14,8 +14,8 @@ import ( const maxSpaceBodyBytes = 1 << 20 // 1 MiB -// maxCapabilitiesBodyBytes caps the capability-set request bodies, which carry only tokens from a -// fixed five-value vocabulary — no content fields. +// maxCapabilitiesBodyBytes caps the capability-set request bodies, which carry only capability +// tokens from a small fixed vocabulary — no content fields. const maxCapabilitiesBodyBytes = 4 * 1024 // 4 KiB // handleGetTeamSpaces handles GET /api/v1/teams/{team_id}/spaces. @@ -88,8 +88,7 @@ func (p *Plugin) handleGetSpace(w http.ResponseWriter, r *http.Request) { // (title, description, icon, props, view_access) are applied onto the existing space; a supplied // empty string clears a string field. The optimistic-lock baseline (expected_update_at) is // required unless force is set. requireSpaceManageGate is the route floor; a ViewAccess change -// that requires the stricter admin gate is enforced inside UpdateSpace itself, against the live -// row, under the space's membership advisory lock. +// requires the stricter admin gate, enforced inside UpdateSpace against the live row. func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index f9a269d..686de5a 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -164,9 +164,11 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID // gates), so no re-read happens here. userID is the acting user, recorded in logs only — a // move does not change the page's LastModifiedBy. requiredOwnerID, when non-empty, requires // every live page in the moved subtree to be owned by it — the gate resolves this to userID on -// the delete_own_page-only path and "" on the delete_page (any) path; a mismatch fails the move -// in-transaction. A same-space request requires only the reparented root to be owned, since no -// other page leaves the space. Per-page restrictions and redirects are not handled yet. +// the delete_own_page-only path and "" on the delete_page (any) path. store.MovePageToSpace +// re-checks this against the exact subtree it moves, so the whole move is rejected if any page +// has a different owner. A same-space request +// requires only the reparented root to be owned, since no other page leaves the space. +// Per-page restrictions and redirects are not handled yet. func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID, requiredOwnerID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -215,9 +217,12 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model // requestedParent is passed explicitly because nil means "target root" here but "leave // unchanged" to MovePage. if sourceSpace.Id == targetSpace.Id { - // The subtree keeps its space, so only the reparented root is affected — an own-only caller - // must own it. A page's UserId never changes after creation, so reading it before the store's - // locked reparent carries no check-then-act window. + // The subtree keeps its space, so the reparented root is the only page that moves: when + // requiredOwnerID is set, it is the only page whose owner has to match. store.MovePage has + // no ownership parameter, so the check runs here rather than inside the store transaction. + // Reading the owner outside that transaction is still safe: a page's UserId is set at + // creation and never updated, so no concurrent write can change the answer between this + // read and the move. if requiredOwnerID != "" { page, pageErr := s.GetPageInSpace("MovePageToSpace", pageID, sourceSpace.Id, false) if pageErr != nil { diff --git a/server/app/permissions.go b/server/app/permissions.go index b286b82..a36fd6e 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -69,9 +69,10 @@ func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, // ResolveSpaceRead resolves the read gate for space against userID, reporting how the read was // admitted so callers can gate auto-join to the fall-through case only. where identifies the -// calling operation for the 500 an isActiveTeamMember lookup failure surfaces as — a genuine -// backend failure must never collapse into ReadDenied, which would misreport an outage as "not -// authorized". +// calling operation for the 500 an isActiveTeamMember lookup failure surfaces as. On that +// failure the returned resolution is ReadDenied but the error is non-nil, so callers must check +// the error first — treating the resolution alone as authoritative would misreport an outage as +// "not authorized". func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID string) (ReadResolution, *mmmodel.AppError) { if appErr := s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { return ReadDenied, appErr @@ -89,8 +90,10 @@ func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID stri return s.readResolutionFrom(false, active, space, userID), nil } -// requireActiveMemberGate runs the four-gate preamble shared by every space-scoped Require* -// helper: client wiring, existence-hiding on a nil space, the sysadmin override, and +// requireActiveMemberGate runs the four-gate preamble shared by RequireSpacePagePermission, +// requireChannelAdminOrTeamPerm, and RequireSpaceAdminOrSysadmin (RequireSpacePagePermissionFrom +// skips it, reusing a ReadResolution its caller already resolved): client wiring, +// existence-hiding on a nil space, the sysadmin override, and // active-team-membership resolution with its 500 on a genuine lookup failure. A non-nil appErr // must be returned by the caller immediately. Otherwise, when sysadmin is true the caller may // return nil immediately; when it is false the caller continues with active to evaluate its own @@ -125,6 +128,14 @@ func (s *Service) RequireSpacePagePermission(where string, space *model.Space, u if sysadmin { return nil } + return s.evaluatePagePermission(where, space, userID, perm, active) +} + +// evaluatePagePermission grants perm to an active member holding it on the backing channel, or — +// for a read permission on an open space only — to an active team member via the non-member +// fall-through. Any other case yields the shared existence-hiding 403. active is the caller's +// already-resolved team-membership status. +func (s *Service) evaluatePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission, active bool) *mmmodel.AppError { if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, perm) { return nil } @@ -157,14 +168,35 @@ func (s *Service) RequireSpacePagePermissionFrom(where string, space *model.Spac if space == nil { return existenceHidingForbidden(where) } - if s.client.User.HasPermissionToChannel(userID, space.ChannelId, perm) { - return nil + return s.evaluatePagePermission(where, space, userID, perm, true) +} + +// ResolveSpacePageOwnOrAny evaluates a two-tier own/any permission pair: anyPerm if held, else +// ownPerm when ownerMatches. Reports whether the caller qualified only via ownPerm (ownOnly), so +// a caller that must push ownership enforcement further down — MovePageToSpace's subtree-wide +// check — can tell the two tiers apart. admitted=false with a nil appErr means neither tier +// admitted the caller; the caller writes its own denial so the operation label stays its own. A +// non-nil appErr is a genuine backend failure from the check itself, which the caller must +// surface as-is rather than reporting as a denial. +func (s *Service) ResolveSpacePageOwnOrAny(space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, admittedVia ReadResolution) (ownOnly, admitted bool, appErr *mmmodel.AppError) { + anyErr := s.RequireSpacePagePermissionFrom(anyWhere, space, userID, anyPerm, admittedVia) + if anyErr == nil { + return false, true, nil } - if perm.Id == mmmodel.PermissionReadPage.Id && space.ViewAccess == model.ViewAccessOpen && - s.openTeamFallthrough(userID, space.TeamId) { - return nil + if anyErr.StatusCode != http.StatusForbidden { + return false, false, anyErr } - return existenceHidingForbidden(where) + if !ownerMatches { + return false, false, nil + } + ownErr := s.RequireSpacePagePermissionFrom(ownWhere, space, userID, ownPerm, admittedVia) + if ownErr == nil { + return true, true, nil + } + if ownErr.StatusCode != http.StatusForbidden { + return false, false, ownErr + } + return false, false, nil } // requireChannelAdminOrTeamPerm gates an elevated space operation: sysadmin, channel admin_space @@ -245,8 +277,9 @@ func spaceIDOrEmpty(space *model.Space) string { // AutoJoinIfDefaultGranted is the auto-join pre-step: when a non-member's write was admitted only via // the open-space read fall-through (admittedVia == ReadViaOpenFallthrough) and the space's -// current default capability set grants perm to a plain member, it silently joins userID to the -// backing channel (idempotent) so the subsequent write-gate re-check passes as a member. The +// current default capability set grants perm to a plain member, it joins userID to the backing +// channel (idempotent, and published as a membership-added event) so the subsequent write-gate +// re-check passes as a member. The // open-read admission is re-validated before joining: a concurrent open->private flip between the // admitting read and this pre-step aborts the join. // ownerCheck, when non-nil, must additionally hold (used for delete_own_page, where the caller diff --git a/server/app/space.go b/server/app/space.go index 5b45a88..6522963 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -80,15 +80,16 @@ func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel. } } -// hasOtherAuthorizedMember reports whether space has at least one backing-channel member other -// than excludeUserID who can still reach the space — for a team space, one who is still an -// active member of the team. Former team members keep their channel-member rows after leaving -// the team, so counting raw rows would let the last reachable member be removed and leave the -// space stranded behind members who all fail the team half of the access gate. -func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID string) (bool, error) { +// hasOtherAuthorizedMemberMatching reports whether space has at least one backing-channel member +// other than excludeUserID that satisfies matches and can still reach the space — for a team +// space, one who is still an active member of the team. Former team members keep their +// channel-member rows after leaving the team, so counting raw rows would let the last reachable +// member be removed and leave the space stranded behind members who all fail the team half of the +// access gate. Iteration stops at the first match. +func (s *Service) hasOtherAuthorizedMemberMatching(space *model.Space, excludeUserID string, matches func(cm *mmmodel.ChannelMember) bool) (bool, error) { found := false err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { - if cm.UserId == excludeUserID { + if cm.UserId == excludeUserID || !matches(cm) { return false, nil } if space.TeamId == "" { @@ -99,8 +100,11 @@ func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID str if activeErr != nil { return false, activeErr } - found = active - return found, nil + if active { + found = true + return true, nil + } + return false, nil }) if err != nil { return false, err @@ -108,6 +112,12 @@ func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID str return found, nil } +// hasOtherAuthorizedMember reports whether any backing-channel member other than excludeUserID can +// still reach the space. +func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID string) (bool, error) { + return s.hasOtherAuthorizedMemberMatching(space, excludeUserID, func(*mmmodel.ChannelMember) bool { return true }) +} + // archiveOrphanChannel archives a backing channel when a later step in space creation fails, // to avoid an orphaned channel. reason describes the step that failed; cause is its error. // Reports whether the channel was archived, which decides whether the same failure path may @@ -356,6 +366,12 @@ func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) if err != nil { return nil, err } + return s.defaultCapabilitiesForRoles(roles) +} + +// defaultCapabilitiesForRoles is spaceDefaultCapabilities for a caller that already holds the +// backing channel's scheme roles. +func (s *Service) defaultCapabilitiesForRoles(roles *store.SchemeRoles) ([]string, error) { if caps, ok := model.DefaultCapabilitiesForSchemeName(roles.SchemeName); ok { return caps, nil } @@ -415,7 +431,8 @@ func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*mode // preset repoints the backing channel at the shared preset scheme; any other set creates a new // immutable space-private custom scheme and repoints, retiring the previous custom scheme once // unreferenced. The repoint goes through pluginapi Channel.Update (not a store-direct write) so -// core's member-cache invalidation runs and the switch is authorization-visible immediately. +// core's member-cache invalidation runs and the new scheme takes effect on the next permission +// check, rather than when the cache expires. func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, actingUserID string) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -446,7 +463,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, // scheme — so without this an unchanged custom set would create, repoint, and retire on // every save, and the id comparison below could never catch it. if currentRolesErr == nil { - if liveCaps, capsErr := s.spaceDefaultCapabilities(space); capsErr == nil && + if liveCaps, capsErr := s.defaultCapabilitiesForRoles(currentRoles); capsErr == nil && slices.Equal(liveCaps, model.NormalizeCapabilitySet(caps)) { return nil } @@ -587,8 +604,8 @@ func normalizeAndValidateSpacePatch(where string, patch *model.SpacePatch) *mmmo // rejected when force=true. actingUserID is used only for that escalation check. // // Any patch on a space with a backing channel runs under the space's membership advisory lock (the -// same lock auto-join and the last-admin invariant use), since it also drives the channel-metadata -// sync below and both writers must serialize against the same lock. +// same lock AutoJoinIfDefaultGranted and the last-admin/last-member guards use), because it also +// drives the channel-metadata sync below, which must not race a concurrent membership change. func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expectedUpdateAt *int64, force bool, actingUserID string) (*model.Space, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("UpdateSpace", "app.space.update.invalid_id.app_error", nil, "", http.StatusBadRequest) diff --git a/server/app/space_members.go b/server/app/space_members.go index 6145e18..9d83dac 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -113,31 +113,11 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac } // hasOtherAuthorizedAdmin reports whether space's backing channel still has a SchemeAdmin member -// who is an active team member once excludeUserID is disregarded — the last-admin invariant's -// authorized-admin predicate (mirroring hasOtherAuthorizedMember's team-active discipline and its -// stop-at-the-first-hit walk). excludeUserID, when non-empty, is skipped, so the answer describes -// what would remain after that user is demoted or removed. +// who can reach the space once excludeUserID is disregarded — the last-admin invariant's +// admin-side counterpart to hasOtherAuthorizedMember. excludeUserID, when non-empty, is skipped, so +// the answer describes what would remain after that user is demoted or removed. func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID string) (bool, error) { - found := false - err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { - if !cm.SchemeAdmin || cm.UserId == excludeUserID { - return false, nil - } - if space.TeamId == "" { - found = true - return true, nil - } - active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) - if activeErr != nil { - return false, activeErr - } - if active { - found = true - return true, nil - } - return false, nil - }) - return found, err + return s.hasOtherAuthorizedMemberMatching(space, excludeUserID, func(cm *mmmodel.ChannelMember) bool { return cm.SchemeAdmin }) } // SetSpaceMemberCapabilities replaces targetUserID's per-member granted capability set. Callers diff --git a/server/app/ws_events.go b/server/app/ws_events.go index fd3b5f6..dc1e7bf 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -11,7 +11,7 @@ import ( // can refresh the affected space list and page tree without a full reload. The platform prepends // "custom__" to each name on the wire, so the names carry no redundant plugin prefix. // -// Every event is scoped to the space's backing channel. Reads are no longer purely +// Every event is scoped to the space's backing channel. Reads are not purely // membership-gated (an open space also admits non-member reads via the team read_public_channel // fall-through, and the team space list includes open spaces the caller hasn't joined), but WS // delivery deliberately stays channel-scoped: a non-member reader of an open space receives no diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go index 8742bc5..b425941 100644 --- a/server/e2e/helpers_test.go +++ b/server/e2e/helpers_test.go @@ -138,8 +138,8 @@ func spaceHasMember(t *testing.T, ctx context.Context, admin actor, spaceID, use return false } -// deleteSpace deletes spaceID via the space admin actor. Registered via t.Cleanup by every -// scenario that creates a space. +// deleteSpace deletes spaceID via the space admin actor. Called at the end of TestScenarios via a +// shared t.Cleanup over every space id the scenarios accumulated in spacesToClean. func deleteSpace(t *testing.T, ctx context.Context, admin actor, spaceID string) { t.Helper() status, body, err := doPluginRequest(ctx, admin.client, http.MethodDelete, "/spaces/"+spaceID, nil, nil) diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index ab4fdd4..0cab1b6 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -20,11 +20,11 @@ import ( pluginmodel "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// TestScenarios ports the seven canonical Confluence space-permission scenarios plus the named -// parity gaps (MM-69269) from scripts/smoke-scenarios.sh to run against a real Mattermost server -// (built from the paired core branch) with the plugin installed, via Testcontainers. See -// scripts/smoke-scenarios.sh for the authoritative behavioral spec this ports; the two suites are -// intentionally kept in lockstep, not merged. +// TestScenarios runs the seven canonical Confluence space-permission scenarios plus the named +// parity gaps against a real Mattermost server (built from the paired core branch) with the +// plugin installed, via Testcontainers. scripts/smoke-scenarios.sh is the authoritative +// behavioral spec and runs the same scenarios as a bash suite; the two are kept in lockstep, not +// merged. func TestScenarios(t *testing.T) { env := getEnv(t) ctx := context.Background() diff --git a/server/internal/testutil/fixtures.go b/server/internal/testutil/fixtures.go index a59cb93..9c119aa 100644 --- a/server/internal/testutil/fixtures.go +++ b/server/internal/testutil/fixtures.go @@ -49,8 +49,9 @@ func OpenTestStore(t *testing.T) (*store.Store, *sql.DB) { // channel's core Channels/Schemes/Roles rows (GetSchemeRolesForChannel, GetSchemeIdByName, // GetRolePermissionsByName) — core tables owned by the paired core branch's migration in // production, absent from this plugin-only isolated schema. Stand them in with just the - // columns the plugin store queries, and seed the three preset schemes (what the core seeding migration, - // mirrored here) so scheme-resolving app methods work against test-created spaces. + // columns the plugin store queries, and seed the three preset schemes the core seeding + // migration creates in production, so scheme-resolving app methods work against test-created + // spaces. mustCreateSchemeStandInTables(t, db) mustSeedSpaceSchemes(t, db) diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index 2989dbe..87ea032 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -15,7 +15,7 @@ import ( // The capability vocabulary is the core page-permission id strings themselves, so the API speaks // the same tokens core enforces — no invented level names. Consumed only as symbols (never a // re-declared permission list), keeping the plugin single-sourced against core (see the drift -// defence discussion on the round-trip tests below). +// defence discussion on the round-trip tests in space_capabilities_test.go). var ( // CapabilityReadPage is the baseline capability, always present in an effective set, and never // independently grantable (see ValidateGrantedCapabilities/ValidateDefaultCapabilities). @@ -92,43 +92,38 @@ var presetCapabilitySets = map[string][]string{ mmmodel.SchemeNameSpaceReadOnly: stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions), } -// ValidateGrantedCapabilities validates a per-member granted-capability request: each token must -// be one of the grantable member capabilities. read_page is rejected as the non-grantable -// baseline and delete_page as admin-only; an unknown token is rejected. Dedup-tolerant. -func ValidateGrantedCapabilities(caps []string) *mmmodel.AppError { +// validateCapabilities validates caps against allowed, rejecting read_page as the non-grantable +// baseline and delete_page as admin-only, plus admin_space when rejectAdmin is set. An unknown +// token is rejected. Dedup-tolerant. where attributes the rejection to the calling validator. +func validateCapabilities(where string, caps []string, allowed map[string]bool, rejectAdmin bool) *mmmodel.AppError { for _, c := range caps { if c == CapabilityReadPage { - return mmmodel.NewAppError("ValidateGrantedCapabilities", "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + return mmmodel.NewAppError(where, "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) } if c == CapabilityDeletePage { - return mmmodel.NewAppError("ValidateGrantedCapabilities", "model.space_capabilities.delete_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + return mmmodel.NewAppError(where, "model.space_capabilities.delete_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + } + if rejectAdmin && c == CapabilityAdminSpace { + return mmmodel.NewAppError(where, "model.space_capabilities.admin_not_a_default.app_error", nil, "", http.StatusBadRequest) } - if !grantableMemberCapabilities[c] { - return mmmodel.NewAppError("ValidateGrantedCapabilities", "model.space_capabilities.unknown_capability.app_error", map[string]any{"Capability": c}, "", http.StatusBadRequest) + if !allowed[c] { + return mmmodel.NewAppError(where, "model.space_capabilities.unknown_capability.app_error", map[string]any{"Capability": c}, "", http.StatusBadRequest) } } return nil } +// ValidateGrantedCapabilities validates a per-member granted-capability request: each token must +// be one of the grantable member capabilities. +func ValidateGrantedCapabilities(caps []string) *mmmodel.AppError { + return validateCapabilities("ValidateGrantedCapabilities", caps, grantableMemberCapabilities, false) +} + // ValidateDefaultCapabilities validates a space-default capability set: same rule as // ValidateGrantedCapabilities, plus admin_space is also rejected — a space default is never // admin-granting. func ValidateDefaultCapabilities(caps []string) *mmmodel.AppError { - for _, c := range caps { - if c == CapabilityReadPage { - return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) - } - if c == CapabilityDeletePage { - return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.delete_page_not_grantable.app_error", nil, "", http.StatusBadRequest) - } - if c == CapabilityAdminSpace { - return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.admin_not_a_default.app_error", nil, "", http.StatusBadRequest) - } - if !grantableDefaultCapabilities[c] { - return mmmodel.NewAppError("ValidateDefaultCapabilities", "model.space_capabilities.unknown_capability.app_error", map[string]any{"Capability": c}, "", http.StatusBadRequest) - } - } - return nil + return validateCapabilities("ValidateDefaultCapabilities", caps, grantableDefaultCapabilities, true) } // RolesForCapabilities maps the requested non-admin capabilities to their atomic role names for diff --git a/server/store/page_move.go b/server/store/page_move.go index a2e0f73..6d72517 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -248,10 +248,9 @@ func (s *Store) reindexSiblingGroup(tx *sqlx.Tx, channelID, parentID, movedPageI // operations between the caller's pre-checks and this call. Cross-owner resources // (page-comment Posts, FileInfo) are not re-homed here. // requiredOwnerID, when non-empty, requires every live page in the moved subtree to be owned by -// that user, failing the move with ErrInvalidInput otherwise; empty skips the check. Checked here, -// against the transaction's own locked subtree read, to close the TOCTOU window a pre-scan would -// leave against a concurrent reparent grafting another user's page into the subtree between check -// and move. +// that user, failing the move with ErrInvalidInput otherwise; empty skips the check. Checked +// against the transaction's own locked subtree read, alongside the other re-validations above, so +// a concurrent reparent cannot graft another user's page into the subtree undetected. func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int, requiredOwnerID string) (_ *model.Page, priorParentID string, err error) { if pageID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} diff --git a/server/store/scheme_store.go b/server/store/scheme_store.go index d3a9644..ec8fbe9 100644 --- a/server/store/scheme_store.go +++ b/server/store/scheme_store.go @@ -111,9 +111,8 @@ func (s *Store) GetRolePermissionsByName(roleName string) ([]string, error) { // CreateSpaceCustomScheme creates one immutable space-private channel scheme with three generated // roles carrying exactly the given permission sets (user/admin/guest), and returns the new -// scheme's id. Used when a space's default capability set (or the canonical admin/guest -// permission sets) doesn't match one of the three seeded presets. The scheme is unreferenced by -// any channel until the caller repoints the space's backing channel at it. +// scheme's id. The scheme is unreferenced by any channel until the caller repoints the space's +// backing channel at it. func (s *Store) CreateSpaceCustomScheme(userPermissions, adminPermissions, guestPermissions []string) (_ string, err error) { tx, cancel, err := s.beginTxBounded() if err != nil { diff --git a/server/store/scheme_store_test.go b/server/store/scheme_store_test.go index 404738d..e3992a9 100644 --- a/server/store/scheme_store_test.go +++ b/server/store/scheme_store_test.go @@ -85,10 +85,8 @@ func TestDeleteSpaceCustomSchemeIfUnreferenced(t *testing.T) { ) require.NoError(t, err) - // Space creation archives its backing channel when a later step fails, and an archived - // channel keeps its SchemeId — the reference count is deliberately DeleteAt-blind, so the - // row stands in for the archived channel. Without the exclusion that abandoned channel - // counts as a live reference and the scheme is orphaned forever. + // Stands in for a channel abandoned by a failed space-creation step (see + // DeleteSpaceCustomSchemeIfUnreferenced's excludeChannelID doc). channelID := mmmodel.NewId() _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) require.NoError(t, err) diff --git a/server/store/space_store.go b/server/store/space_store.go index 86fe503..3268442 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -366,9 +366,8 @@ const ( // membership mutations for one space across processes. Guards that span multiple non-database // calls — read the member list, then mutate it — are atomic with respect to each other only // under this lock. The lock is session-scoped on a dedicated pooled connection with no open -// transaction of its own. fn may perform store work of its own — reads, and its own transactions -// (e.g. the ViewAccess live-row compare and UpdateSpace's write, or the auto-join pre-step's -// reads) — so each in-flight caller holds two pooled connections for the critical section: this +// transaction of its own. fn may perform store work of its own — reads and its own transactions +// — so each in-flight caller holds two pooled connections for the critical section: this // lock's session connection plus any connection fn's own transaction acquires. Any transaction fn // opens must therefore bound its connection acquisition (see beginTxBounded), or a saturated pool // can leave every lock holder waiting on a connection no other holder will release. fn must also From ab317b9c8af642a877d65dc6c09fb933e921700a Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 00:07:03 +0200 Subject: [PATCH 12/28] some renaming --- server/api.go | 21 +++++++++++---------- server/api_handler_test.go | 10 +++++----- server/api_space.go | 24 ++++++++++++------------ server/app/space.go | 4 ++-- server/app/space_members.go | 14 +++++++------- server/app/space_test.go | 6 +++--- server/e2e/helpers_test.go | 2 +- server/internal/testutil/fixtures.go | 2 +- server/store/scheme_store.go | 4 ++-- 9 files changed, 44 insertions(+), 43 deletions(-) diff --git a/server/api.go b/server/api.go index 07638e3..054ed34 100644 --- a/server/api.go +++ b/server/api.go @@ -24,9 +24,10 @@ import ( // Authorization: every route requires an authenticated user via MattermostAuthorizationRequired. // Every space- and page-scoped handler additionally gates on the capability-based RBAC model: // requireSpaceRead/requireSpacePagePerm for reads, gatePageWrite/gateDeleteOwnOrAny for page -// writes (with the open-space auto-join pre-step), requireSpaceManageGate for membership -// management and general space-field updates, and requireSpaceAdminGate/requireSpaceDeleteGate -// for the space-wide exposure-policy and delete/restore operations. See +// writes (with the open-space auto-join pre-step), requireSpaceManage for membership management +// and general space-field updates, and requireSpaceAdmin/requireSpaceDelete for the space-wide +// exposure-policy and delete/restore operations. A require* helper resolves the space by id and +// then gates it; a gate* helper gates a space the caller already holds. See // server/app/permissions.go for the gate implementations. func (p *Plugin) initRouter() *mux.Router { router := mux.NewRouter() @@ -45,7 +46,7 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/restore", p.handleRestoreSpace).Methods(http.MethodPatch) // Space membership. - api.HandleFunc("/spaces/{space_id}/members", p.handleListSpaceMembers).Methods(http.MethodGet) + api.HandleFunc("/spaces/{space_id}/members", p.handleGetSpaceMembers).Methods(http.MethodGet) api.HandleFunc("/spaces/{space_id}/members", p.handleAddSpaceMember).Methods(http.MethodPost) api.HandleFunc("/spaces/{space_id}/members/{user_id}", p.handleRemoveSpaceMember).Methods(http.MethodDelete) api.HandleFunc("/spaces/{space_id}/members/{user_id}/capabilities", p.handleSetSpaceMemberCapabilities).Methods(http.MethodPatch) @@ -151,27 +152,27 @@ func (p *Plugin) requireSpacePagePerm(w http.ResponseWriter, spaceID, userID str }) } -// requireSpaceManageGate gates a route on requireSpaceManage: sysadmin, channel admin_space, or +// requireSpaceManage gates a route on Service.RequireSpaceManage: sysadmin, channel admin_space, or // (once the read resolver has already admitted the caller) team manage_space. -func (p *Plugin) requireSpaceManageGate(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { +func (p *Plugin) requireSpaceManage(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { return p.service.RequireSpaceManage("api.space.manage", space, userID) }) } -// requireSpaceAdminGate gates a route on requireSpaceAdminOrSysadmin — the space-wide +// requireSpaceAdmin gates a route on Service.RequireSpaceAdminOrSysadmin — the space-wide // exposure-policy knobs (ViewAccess, default capabilities). -func (p *Plugin) requireSpaceAdminGate(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { +func (p *Plugin) requireSpaceAdmin(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { return p.service.RequireSpaceAdminOrSysadmin("api.space.admin", space, userID) }) } -// requireSpaceDeleteGate gates space delete/restore: sysadmin, channel admin_space, or (once the +// requireSpaceDelete gates space delete/restore: sysadmin, channel admin_space, or (once the // read resolver has already admitted the caller) team delete_space. includeDeleted must be true // for restore, where the space is soft-deleted at lookup time; the read resolver and the delete // gate then evaluate against that soft-deleted record. -func (p *Plugin) requireSpaceDeleteGate(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { +func (p *Plugin) requireSpaceDelete(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { return p.requireSpaceGate(w, spaceID, includeDeleted, func(space *model.Space) *mmmodel.AppError { return p.service.RequireSpaceDeleteAuthority("api.space.delete", space, userID) }) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 2cf78e1..88d7c9d 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -476,10 +476,10 @@ func TestHandler_PageCollectionsReturnMetadataOnly(t *testing.T) { require.Contains(t, detail, "props") } -// TestHandler_ListSpaceMembers lists a space's members through the backing channel. The members +// TestHandler_GetSpaceMembers lists a space's members through the backing channel. The members // list is manage-gated — deliberately stricter than MM channels, since the projection // carries the per-member capability matrix. -func TestHandler_ListSpaceMembers(t *testing.T) { +func TestHandler_GetSpaceMembers(t *testing.T) { channelID := mmmodel.NewId() memberUserID := mmmodel.NewId() adminID := mmmodel.NewId() @@ -506,10 +506,10 @@ func TestHandler_ListSpaceMembers(t *testing.T) { require.False(t, resp.HasMore) } -// TestHandler_ListSpaceMembers_HasMore verifies the probe row: when the requested page comes back +// TestHandler_GetSpaceMembers_HasMore verifies the probe row: when the requested page comes back // full and another member exists on the next page, has_more is true and the page is trimmed to // per_page entries. -func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { +func TestHandler_GetSpaceMembers_HasMore(t *testing.T) { channelID := mmmodel.NewId() firstMember := mmmodel.NewId() secondMember := mmmodel.NewId() @@ -1721,7 +1721,7 @@ func TestHandler_CreateSpace_RequiresCreateSpacePermission(t *testing.T) { } // TestHandler_AddSpaceMember_GuestProjectsReadOnly verifies a guest added to a space is projected -// through the same reverse-role logic as ListSpaceMembers/SetSpaceMemberCapabilities: is_guest +// through the same reverse-role logic as GetSpaceMembers/SetSpaceMemberCapabilities: is_guest // true and capabilities read_page-only, never the space default. func TestHandler_AddSpaceMember_GuestProjectsReadOnly(t *testing.T) { channelID := mmmodel.NewId() diff --git a/server/api_space.go b/server/api_space.go index bde6edd..8b40922 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -87,12 +87,12 @@ func (p *Plugin) handleGetSpace(w http.ResponseWriter, r *http.Request) { // handleUpdateSpace handles PATCH /api/v1/spaces/{space_id}. Only the supplied mutable fields // (title, description, icon, props, view_access) are applied onto the existing space; a supplied // empty string clears a string field. The optimistic-lock baseline (expected_update_at) is -// required unless force is set. requireSpaceManageGate is the route floor; a ViewAccess change +// required unless force is set. requireSpaceManage is the route floor; a ViewAccess change // requires the stricter admin gate, enforced inside UpdateSpace against the live row. func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceManageGate(w, spaceID, userID) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } @@ -122,7 +122,7 @@ func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleDeleteSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceDeleteGate(w, spaceID, userID, false) + space, ok := p.requireSpaceDelete(w, spaceID, userID, false) if !ok { return } @@ -138,7 +138,7 @@ func (p *Plugin) handleDeleteSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleRestoreSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - if _, ok := p.requireSpaceDeleteGate(w, spaceID, userID, true); !ok { + if _, ok := p.requireSpaceDelete(w, spaceID, userID, true); !ok { return } restored, appErr := p.service.RestoreSpace(spaceID) @@ -166,16 +166,16 @@ func (p *Plugin) handleGetSpacePages(w http.ResponseWriter, r *http.Request) { writePaginatedJSON(w, pages, page, perPage, hasMore) } -// handleListSpaceMembers handles GET /api/v1/spaces/{space_id}/members. -func (p *Plugin) handleListSpaceMembers(w http.ResponseWriter, r *http.Request) { +// handleGetSpaceMembers handles GET /api/v1/spaces/{space_id}/members. +func (p *Plugin) handleGetSpaceMembers(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceManageGate(w, spaceID, userID) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } page, perPage := pageParam(r), perPageParam(r) - members, hasMore, appErr := p.service.ListSpaceMembers(space, page, perPage) + members, hasMore, appErr := p.service.GetSpaceMembers(space, page, perPage) if appErr != nil { p.writeAppError(w, appErr) return @@ -190,7 +190,7 @@ func (p *Plugin) handleListSpaceMembers(w http.ResponseWriter, r *http.Request) func (p *Plugin) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceManageGate(w, spaceID, userID) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } @@ -220,7 +220,7 @@ func (p *Plugin) handleSetSpaceMemberCapabilities(w http.ResponseWriter, r *http vars := mux.Vars(r) spaceID := vars["space_id"] targetUserID := vars["user_id"] - space, ok := p.requireSpaceManageGate(w, spaceID, userID) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } @@ -242,7 +242,7 @@ func (p *Plugin) handleSetSpaceMemberCapabilities(w http.ResponseWriter, r *http func (p *Plugin) handleSetSpaceDefaultCapabilities(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceAdminGate(w, spaceID, userID) + space, ok := p.requireSpaceAdmin(w, spaceID, userID) if !ok { return } @@ -274,7 +274,7 @@ func (p *Plugin) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) if targetUserID == userID { space, _, ok = p.requireSpaceRead(w, spaceID, userID) } else { - space, ok = p.requireSpaceManageGate(w, spaceID, userID) + space, ok = p.requireSpaceManage(w, spaceID, userID) } if !ok { return diff --git a/server/app/space.go b/server/app/space.go index 6522963..b42a27e 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -146,7 +146,7 @@ func (s *Service) resolveSpaceScheme(caps []string) (schemeID string, createdCus // into the generated role's Permissions column. caps = model.NormalizeCapabilitySet(caps) if presetName, ok := model.SchemeNameForDefaultCapabilities(caps); ok { - id, getErr := s.store.GetSchemeIdByName(presetName) + id, getErr := s.store.GetSchemeIDByName(presetName) if getErr != nil { return "", false, getErr } @@ -524,7 +524,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, return s.BuildSpaceWithAccess(fresh, actingUserID) } -// ListSpaceMembers, AddSpaceMember, SetSpaceMemberCapabilities, and RemoveSpaceMember live in +// GetSpaceMembers, AddSpaceMember, SetSpaceMemberCapabilities, and RemoveSpaceMember live in // space_members.go alongside the escalation and last-admin guards. // GetSpacesForTeam returns one page of a team's live spaces, plus whether more exist beyond it. diff --git a/server/app/space_members.go b/server/app/space_members.go index 9d83dac..7ae4864 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -14,28 +14,28 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// ListSpaceMembers returns one page of space's members plus whether more members exist beyond +// GetSpaceMembers returns one page of space's members plus whether more members exist beyond // it, each projected to their effective/granted capabilities. page/perPage are normalized // like every other paginated method (page and perPage both clamped). The pluginapi member // listing is page-indexed rather than offset-based, so when the requested page comes back full a // one-row probe at the next page's first slot decides has-more. space is the caller's // already-fetched record (from its manage gate), so no re-read here. -func (s *Service) ListSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { +func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { if space == nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } - if appErr := s.requireClient("ListSpaceMembers", "space_id", space.Id); appErr != nil { + if appErr := s.requireClient("GetSpaceMembers", "space_id", space.Id); appErr != nil { return nil, false, appErr } defaultCaps, err := s.spaceDefaultCapabilities(space) if err != nil { - return nil, false, storeAppError("ListSpaceMembers", err) + return nil, false, storeAppError("GetSpaceMembers", err) } page = ClampPage(page) perPage = ClampPerPage(perPage) channelMembers, err := s.client.Channel.ListMembers(space.ChannelId, page, perPage) if err != nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } members := make([]*model.SpaceMember, 0, len(channelMembers)) for _, cm := range channelMembers { @@ -48,7 +48,7 @@ func (s *Service) ListSpaceMembers(space *model.Space, page, perPage int) ([]*mo // beyond the current window. probe, probeErr := s.client.Channel.ListMembers(space.ChannelId, (page+1)*perPage, 1) if probeErr != nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) } hasMore = len(probe) > 0 } diff --git a/server/app/space_test.go b/server/app/space_test.go index 39836e9..a2d5728 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -734,9 +734,9 @@ func createSpaceForMemberTests(t *testing.T, h *testHarness, mockAPI *plugintest return space, creatorID } -// TestServiceListSpaceMembers_ListFails verifies that a failed member listing on the backing +// TestServiceGetSpaceMembers_ListFails verifies that a failed member listing on the backing // channel propagates as a 500 with the list_members error key. -func TestServiceListSpaceMembers_ListFails(t *testing.T) { +func TestServiceGetSpaceMembers_ListFails(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) space, _ := createSpaceForMemberTests(t, h, mockAPI) @@ -744,7 +744,7 @@ func TestServiceListSpaceMembers_ListFails(t *testing.T) { mockAPI.On("GetChannelMembers", space.ChannelId, 0, 60). Return(nil, &mmmodel.AppError{Id: "app.channel.get_members.app_error", StatusCode: http.StatusInternalServerError}) - _, _, appErr := h.svc.ListSpaceMembers(space, 0, 60) + _, _, appErr := h.svc.GetSpaceMembers(space, 0, 60) require.NotNil(t, appErr) require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) require.Equal(t, "app.space.list_members.failed.app_error", appErr.Id) diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go index b425941..a4d4848 100644 --- a/server/e2e/helpers_test.go +++ b/server/e2e/helpers_test.go @@ -110,7 +110,7 @@ func addSpaceMember(t *testing.T, ctx context.Context, admin actor, spaceID, use require.Equal(t, http.StatusCreated, status, "add member %s to space %s: %s", userID, spaceID, body) } -// spaceMembersResponse is the paginated member-list shape handleListSpaceMembers writes (see +// spaceMembersResponse is the paginated member-list shape handleGetSpaceMembers writes (see // paginatedResponse in server/api.go) — redeclared here since that type is unexported. type spaceMembersResponse struct { Items []*spaceMemberJSON `json:"items"` diff --git a/server/internal/testutil/fixtures.go b/server/internal/testutil/fixtures.go index 9c119aa..e2b387c 100644 --- a/server/internal/testutil/fixtures.go +++ b/server/internal/testutil/fixtures.go @@ -46,7 +46,7 @@ func OpenTestStore(t *testing.T) (*store.Store, *sql.DB) { require.NoError(t, err, "create ChannelMembers stand-in") // The RBAC capability model resolves a space's default capability set through its backing - // channel's core Channels/Schemes/Roles rows (GetSchemeRolesForChannel, GetSchemeIdByName, + // channel's core Channels/Schemes/Roles rows (GetSchemeRolesForChannel, GetSchemeIDByName, // GetRolePermissionsByName) — core tables owned by the paired core branch's migration in // production, absent from this plugin-only isolated schema. Stand them in with just the // columns the plugin store queries, and seed the three preset schemes the core seeding diff --git a/server/store/scheme_store.go b/server/store/scheme_store.go index ec8fbe9..f9a55eb 100644 --- a/server/store/scheme_store.go +++ b/server/store/scheme_store.go @@ -65,9 +65,9 @@ func (s *Store) GetSchemeRolesForChannel(channelID string) (*SchemeRoles, error) return &roles, nil } -// GetSchemeIdByName returns the id of the scheme with the given name, or ErrNotFound if none +// GetSchemeIDByName returns the id of the scheme with the given name, or ErrNotFound if none // exists. -func (s *Store) GetSchemeIdByName(name string) (string, error) { +func (s *Store) GetSchemeIDByName(name string) (string, error) { if name == "" { return "", &ErrInvalidInput{Entity: "Scheme", Field: "name", Value: name} } From 1123f23545253363db323209e258b107e36a943d Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 00:46:37 +0200 Subject: [PATCH 13/28] further simplifications --- server/api.go | 29 ++++++++++++++---- server/api_handler_test.go | 7 +---- server/api_page.go | 35 ++++++++++------------ server/app/page_move_to_space_test.go | 7 +---- server/app/permissions.go | 22 +++----------- server/app/service.go | 12 ++++++++ server/app/space.go | 12 ++------ server/app/space_members.go | 10 +++++-- server/e2e/helpers_test.go | 9 +++--- server/e2e/scenarios_test.go | 5 ++-- server/internal/testutil/fixtures.go | 10 +++++++ server/model/space_capabilities.go | 42 +++++++++++---------------- server/store/scheme_store.go | 17 +++++++++-- 13 files changed, 113 insertions(+), 104 deletions(-) diff --git a/server/api.go b/server/api.go index 054ed34..b587370 100644 --- a/server/api.go +++ b/server/api.go @@ -187,6 +187,12 @@ func (p *Plugin) gatePageWrite(w http.ResponseWriter, space *model.Space, userID if !ok { return false } + return p.gatePageWriteFrom(w, space, userID, perm, resolution) +} + +// gatePageWriteFrom is gatePageWrite for a caller that has already resolved the read gate for the +// same space and user, so that resolution is not derived a second time. +func (p *Plugin) gatePageWriteFrom(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, resolution app.ReadResolution) bool { if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, nil); appErr != nil { p.writeAppError(w, appErr) return false @@ -207,16 +213,27 @@ func (p *Plugin) gateDeleteOwnOrAny(w http.ResponseWriter, space *model.Space, u p.writeAppError(w, appErr) return false } - _, ok, permErr := p.service.ResolveSpacePageOwnOrAny(space, userID, "api.page.delete", mmmodel.PermissionDeletePage, "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) + _, ok := p.resolveOwnOrAnyOrDeny(w, "gateDeleteOwnOrAny", space, userID, + "api.page.delete", mmmodel.PermissionDeletePage, + "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) + return ok +} + +// resolveOwnOrAnyOrDeny resolves a two-tier own/any permission pair against an already-resolved +// read, mapping a denial to the shared existence-hiding 403 under where. It reports whether the +// caller qualified only via ownPerm, which a caller that must push ownership enforcement further +// down needs. Writes the error response and returns ok=false on failure. +func (p *Plugin) resolveOwnOrAnyOrDeny(w http.ResponseWriter, where string, space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, resolution app.ReadResolution) (ownOnly, ok bool) { + ownOnly, allowed, permErr := p.service.ResolveSpacePageOwnOrAny(space, userID, anyWhere, anyPerm, ownWhere, ownPerm, ownerMatches, resolution) if permErr != nil { p.writeAppError(w, permErr) - return false + return false, false } - if !ok { - p.writeAppError(w, mmmodel.NewAppError("gateDeleteOwnOrAny", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) - return false + if !allowed { + p.writeAppError(w, mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + return false, false } - return true + return ownOnly, true } // resolveSpaceReadOrDeny resolves the read gate that precedes every page read and page-write gate, diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 88d7c9d..b8049cb 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -142,14 +142,9 @@ func seedSpace(t *testing.T, s *store.Store, db *sql.DB, channelID string) *mode return seedSpaceInTeam(t, s, db, channelID, mmmodel.NewId()) } -// seedSpaceInTeam mirrors testutil.MustCreateSpace's (channelID, teamID) parameter order. db -// seeds a matching Channels stand-in row pointed at the contribute preset scheme, mirroring -// production CreateSpace's default so scheme-resolving app/handler paths work in tests. func seedSpaceInTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() - space := testutil.MustCreateSpace(t, s, channelID, teamID) - testutil.MustSeedChannelScheme(t, db, channelID, mmmodel.SchemeNameSpaceContribute) - return space + return testutil.MustCreateSpaceWithScheme(t, s, db, channelID, teamID) } func seedPage(t *testing.T, s *store.Store, spaceID, channelID, parentID string) *model.Page { diff --git a/server/api_page.go b/server/api_page.go index 739ec0f..c46a613 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/mux" mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" ) @@ -28,13 +29,12 @@ const maxPageStructBodyBytes = 4 * 1024 // 4 KiB // writes the error response and returns ok=false on failure. Callers pass the invalid-ID // rejection as a pre-built AppError with a string-literal ID so the i18n extraction tool can // discover the message key. -func (p *Plugin) resolveTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, bool) { +func (p *Plugin) resolveTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, app.ReadResolution, bool) { if !mmmodel.IsValidId(targetSpaceID) { p.writeAppError(w, invalidIDErr) - return nil, false + return nil, app.ReadDenied, false } - space, _, ok := p.requireSpaceRead(w, targetSpaceID, userID) - return space, ok + return p.requireSpaceRead(w, targetSpaceID, userID) } // handleCreatePage handles POST /api/v1/spaces/{space_id}/pages. @@ -205,7 +205,7 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - sourceSpace, _, ok := p.requireSpaceRead(w, vars["space_id"], userID) + sourceSpace, sourceRead, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -218,17 +218,17 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleDuplicatePage", true) { return } - // A nil targetSpace means "duplicate into the source space"; the fetched records are passed - // through so the service never re-reads them. - targetSpace := sourceSpace + // An omitted or same-space target duplicates into the source space; the fetched records and the + // read each was admitted by are passed through so neither is resolved twice. + targetSpace, targetRead := sourceSpace, sourceRead if req.TargetSpaceId != "" && req.TargetSpaceId != vars["space_id"] { var targetOK bool - targetSpace, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + targetSpace, targetRead, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) if !targetOK { return } } - if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage) { + if !p.gatePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { return } @@ -285,10 +285,10 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { } // The fetched records are passed through so the service never re-reads them; a same-space // move reuses the membership gate's record instead of resolving the same space twice. - targetSpace := sourceSpace + targetSpace, targetRead := sourceSpace, sourceRead if req.TargetSpaceId != vars["space_id"] { var targetOK bool - targetSpace, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + targetSpace, targetRead, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) if !targetOK { return } @@ -299,15 +299,10 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { // only the target side admits a non-member write. Resolved before the target gate below, which // can join the caller to the target space: a caller denied here must not be left holding a // membership the rejected request created. - ownOnly, allowed, permErr := p.service.ResolveSpacePageOwnOrAny(sourceSpace, userID, + ownOnly, ok := p.resolveOwnOrAnyOrDeny(w, "handleMovePageToSpace", sourceSpace, userID, "api.page.move_to_space", mmmodel.PermissionDeletePage, "api.page.move_to_space.own", mmmodel.PermissionDeleteOwnPage, true, sourceRead) - if permErr != nil { - p.writeAppError(w, permErr) - return - } - if !allowed { - p.writeAppError(w, mmmodel.NewAppError("handleMovePageToSpace", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + if !ok { return } requiredOwnerID := "" @@ -315,7 +310,7 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { requiredOwnerID = userID } - if !p.gatePageWrite(w, targetSpace, userID, mmmodel.PermissionCreatePage) { + if !p.gatePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { return } diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index f743d63..069b7e7 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -20,14 +20,9 @@ import ( ) // seedSpaceForTeam creates a space with a caller-chosen team id (mustCreateSpace randomizes it). -// db seeds a matching Channels stand-in row pointed at the contribute preset scheme, mirroring -// production CreateSpace's default so scheme-resolving app methods (spaceDefaultCapabilities, -// GetSchemeRolesForChannel) work against the space's channel in tests. func seedSpaceForTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() - space := testutil.MustCreateSpace(t, s, channelID, teamID) - testutil.MustSeedChannelScheme(t, db, channelID, mmmodel.SchemeNameSpaceContribute) - return space + return testutil.MustCreateSpaceWithScheme(t, s, db, channelID, teamID) } func pageIDs(pages []*model.PageSummary) map[string]bool { diff --git a/server/app/permissions.go b/server/app/permissions.go index a36fd6e..8d204af 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -4,7 +4,6 @@ package app import ( - "errors" "net/http" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -74,20 +73,11 @@ func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, // the error first — treating the resolution alone as authoritative would misreport an outage as // "not authorized". func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID string) (ReadResolution, *mmmodel.AppError) { - if appErr := s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { + active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { return ReadDenied, appErr } - if space == nil { - return ReadDenied, existenceHidingForbidden(where) - } - if s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) { - return ReadViaSysadmin, nil - } - active, err := s.isActiveTeamMember(space.TeamId, userID) - if err != nil { - return ReadDenied, mmmodel.NewAppError(where, "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - return s.readResolutionFrom(false, active, space, userID), nil + return s.readResolutionFrom(sysadmin, active, space, userID), nil } // requireActiveMemberGate runs the four-gate preamble shared by RequireSpacePagePermission, @@ -337,11 +327,7 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad return nil }) if lockErr != nil { - var appErr *mmmodel.AppError - if errors.As(lockErr, &appErr) { - return false, appErr - } - return false, storeAppError("AutoJoinIfDefaultGranted", lockErr) + return false, lockAppError("AutoJoinIfDefaultGranted", lockErr) } // Published after the lock is released: the membership lock holds a dedicated connection, so a // slow publish inside it would push concurrent membership mutations into a lock timeout. diff --git a/server/app/service.go b/server/app/service.go index c6fcabc..85b3244 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -105,6 +105,18 @@ func requireBaseline(where, field string, baseline *int64, force bool) *mmmodel. return nil } +// lockAppError maps a WithSpaceMembershipLock failure to an *AppError: an AppError the locked +// closure returned is surfaced as-is, and the store's own errors — notably the retryable +// ErrConflict a lock-acquisition timeout yields — go through storeAppError so they keep their +// conventional status codes rather than collapsing to a 500. +func lockAppError(where string, lockErr error) *mmmodel.AppError { + var appErr *mmmodel.AppError + if errors.As(lockErr, &appErr) { + return appErr + } + return storeAppError(where, lockErr) +} + // storeAppError maps a store sentinel error to an *AppError with the conventional status code // and a shared message key (app.store.*); the where argument identifies the calling operation for logs. // This is the default for translating store errors; hand-roll an inline NewAppError only when a diff --git a/server/app/space.go b/server/app/space.go index b42a27e..46f4c4a 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -499,11 +499,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, return nil }) if lockErr != nil { - var appErr *mmmodel.AppError - if errors.As(lockErr, &appErr) { - return nil, appErr - } - return nil, storeAppError("SetSpaceDefaultCapabilities", lockErr) + return nil, lockAppError("SetSpaceDefaultCapabilities", lockErr) } fresh, getErr := s.GetSpace(space.Id) @@ -688,11 +684,7 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec return nil }) if lockErr != nil { - var appErr *mmmodel.AppError - if errors.As(lockErr, &appErr) { - return nil, appErr - } - return nil, storeAppError("UpdateSpace", lockErr) + return nil, lockAppError("UpdateSpace", lockErr) } return result, nil } diff --git a/server/app/space_members.go b/server/app/space_members.go index 7ae4864..73f68bd 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -12,6 +12,7 @@ import ( "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" ) // GetSpaceMembers returns one page of space's members plus whether more members exist beyond @@ -152,8 +153,10 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st var appErr *mmmodel.AppError var newRoles string var newSchemeAdmin bool + var schemeRoles *store.SchemeRoles lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { - schemeRoles, rolesErr := s.store.GetSchemeRolesForChannel(space.ChannelId) + var rolesErr error + schemeRoles, rolesErr = s.store.GetSchemeRolesForChannel(space.ChannelId) if rolesErr != nil { appErr = storeAppError("SetSpaceMemberCapabilities", rolesErr) return appErr @@ -211,7 +214,10 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st return nil, storeAppError("SetSpaceMemberCapabilities", lockErr) } - defaultCaps, defErr := s.spaceDefaultCapabilities(space) + // The scheme roles read under the lock still describe this channel: only + // SetSpaceDefaultCapabilities repoints a channel's scheme, and it serializes behind the same + // space-keyed lock, so the default capability set is projected from them rather than re-read. + defaultCaps, defErr := s.defaultCapabilitiesForRoles(schemeRoles) if defErr != nil { return nil, storeAppError("SetSpaceMemberCapabilities", defErr) } diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go index a4d4848..dde551d 100644 --- a/server/e2e/helpers_test.go +++ b/server/e2e/helpers_test.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -165,11 +166,9 @@ func editPageReq(baseEditAt int64, searchText string) map[string]any { // appErrorID extracts the AppError id from a plugin JSON error body, for asserting on the specific // error rather than just the status code. func appErrorID(body []byte) string { - var er struct { - Id string `json:"id"` - } - if err := json.Unmarshal(body, &er); err != nil { + var appErr *mmmodel.AppError + if !errors.As(mmmodel.AppErrorFromJSON(bytes.NewReader(body)), &appErr) { return "" } - return er.Id + return appErr.Id } diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index 0cab1b6..a398b66 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" "testing" "time" @@ -480,8 +481,6 @@ func TestScenarios(t *testing.T) { // syntactically valid but nonexistent id from a real one. func reverseString(s string) string { r := []rune(s) - for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { - r[i], r[j] = r[j], r[i] - } + slices.Reverse(r) return string(r) } diff --git a/server/internal/testutil/fixtures.go b/server/internal/testutil/fixtures.go index e2b387c..1c588de 100644 --- a/server/internal/testutil/fixtures.go +++ b/server/internal/testutil/fixtures.go @@ -209,6 +209,16 @@ func MustCreateSpace(t *testing.T, s *store.Store, channelID, teamID string) *mo return space } +// MustCreateSpaceWithScheme is MustCreateSpace plus a matching Channels stand-in row pointed at +// the contribute preset scheme, mirroring production CreateSpace's default so scheme-resolving +// paths (spaceDefaultCapabilities, GetSchemeRolesForChannel) work against the space's channel. +func MustCreateSpaceWithScheme(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { + t.Helper() + space := MustCreateSpace(t, s, channelID, teamID) + MustSeedChannelScheme(t, db, channelID, mmmodel.SchemeNameSpaceContribute) + return space +} + // MustCreatePage saves the standard page fixture (NewPage) through the store, failing the test // on error. The depth cap is bypassed (UncappedMaxDepth): some callers build chains deeper than // the app-layer cap to exercise store.MaxPageHierarchyDepth instead. diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index 87ea032..dc9e96c 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -61,13 +61,15 @@ var capabilityAtomicRole = map[string]string{ } // atomicRoleCapability is the reverse of capabilityAtomicRole, used to parse a stored -// ExplicitRoles string back into the granted capability set. -var atomicRoleCapability = map[string]string{ - mmmodel.SpacePageCreatorRoleId: CapabilityCreatePage, - mmmodel.SpacePageCommenterRoleId: CapabilityCommentPage, - mmmodel.SpacePageEditorRoleId: CapabilityEditPage, - mmmodel.SpacePageDeleterOwnRoleId: CapabilityDeleteOwnPage, -} +// ExplicitRoles string back into the granted capability set. Derived from capabilityAtomicRole so +// the two cannot drift. +var atomicRoleCapability = func() map[string]string { + m := make(map[string]string, len(capabilityAtomicRole)) + for capability, roleName := range capabilityAtomicRole { + m[roleName] = capability + } + return m +}() // stripReadPage projects a core permission slice onto its wire id strings with the implicit // read_page baseline removed, so a canonical core permission set can be single-sourced into the @@ -178,28 +180,18 @@ func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, grantedList := NormalizeCapabilitySet(slices.Collect(maps.Keys(granted))) effective := map[string]bool{CapabilityReadPage: true} - switch { - case schemeGuest: - for _, c := range grantedList { - effective[c] = true - } - case schemeAdmin: - for _, c := range spaceAdminEffectiveCapabilities { - effective[c] = true + if !schemeGuest { + if schemeAdmin { + for _, c := range spaceAdminEffectiveCapabilities { + effective[c] = true + } } for _, c := range defaultCaps { effective[c] = true } - for _, c := range grantedList { - effective[c] = true - } - default: - for _, c := range defaultCaps { - effective[c] = true - } - for _, c := range grantedList { - effective[c] = true - } + } + for _, c := range grantedList { + effective[c] = true } return MemberCapabilities{ diff --git a/server/store/scheme_store.go b/server/store/scheme_store.go index f9a55eb..ef443ca 100644 --- a/server/store/scheme_store.go +++ b/server/store/scheme_store.go @@ -23,6 +23,17 @@ const spaceCustomSchemeDisplayName = "Space Custom Scheme" // prove a scheme is one this store created before deleting it. const spaceCustomSchemeNamePrefix = "docs_space_custom_" +// The generated roles' DisplayName prefixes, mirroring the values core's own scheme store writes +// (its SchemeRoleDisplayNameChannel* constants) so a space-private scheme is indistinguishable +// from a core-created one in the admin console. They are restated here rather than imported: +// those constants live in server/v8, which this module pins as a test-harness-only dependency +// contributing no runtime symbols. +const ( + schemeRoleDisplayNameChannelUser = "Channel User Role for Scheme" + schemeRoleDisplayNameChannelAdmin = "Channel Admin Role for Scheme" + schemeRoleDisplayNameChannelGuest = "Channel Guest Role for Scheme" +) + // SchemeRoles is the generated channel-scheme role names governing one backing channel's scheme. // Space capability grants must reference these generated names, not the literal // channel_user/channel_admin roles: on a scheme-backed channel, core rejects the literal. @@ -125,15 +136,15 @@ func (s *Store) CreateSpaceCustomScheme(userPermissions, adminPermissions, guest schemeName := spaceCustomSchemeNamePrefix + mmmodel.NewId() now := mmmodel.GetMillis() - userRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, "Channel User Role for Scheme", userPermissions, now) + userRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, schemeRoleDisplayNameChannelUser, userPermissions, now) if roleErr != nil { return "", roleErr } - adminRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, "Channel Admin Role for Scheme", adminPermissions, now) + adminRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, schemeRoleDisplayNameChannelAdmin, adminPermissions, now) if roleErr != nil { return "", roleErr } - guestRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, "Channel Guest Role for Scheme", guestPermissions, now) + guestRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, schemeRoleDisplayNameChannelGuest, guestPermissions, now) if roleErr != nil { return "", roleErr } From 1e55684042a2a7b96fb3b73e66a48a5ca2572d33 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 16:54:02 +0200 Subject: [PATCH 14/28] Tighten permission-layer comments and unify authorization helper naming --- server/api.go | 38 ++++++++--------- server/api_handler_test.go | 6 +-- server/api_page.go | 26 +++++------ server/app/page_hierarchy.go | 10 ++--- server/app/permissions.go | 57 +++++++++++-------------- server/app/service.go | 6 +-- server/app/space.go | 12 +++--- server/app/space_members.go | 3 +- server/app/ws_events.go | 2 +- server/e2e/container_test.go | 5 ++- server/model/space_capabilities.go | 4 +- server/model/space_capabilities_test.go | 4 +- server/store/page_store.go | 2 +- server/store/space_store.go | 8 ++-- server/store/store_test.go | 12 +++--- 15 files changed, 95 insertions(+), 100 deletions(-) diff --git a/server/api.go b/server/api.go index b587370..3f9f9f8 100644 --- a/server/api.go +++ b/server/api.go @@ -23,7 +23,7 @@ import ( // // Authorization: every route requires an authenticated user via MattermostAuthorizationRequired. // Every space- and page-scoped handler additionally gates on the capability-based RBAC model: -// requireSpaceRead/requireSpacePagePerm for reads, gatePageWrite/gateDeleteOwnOrAny for page +// requireSpaceRead/requireSpacePagePerm for reads, requirePageWrite/requireDeleteOwnOrAnyFrom for page // writes (with the open-space auto-join pre-step), requireSpaceManage for membership management // and general space-field updates, and requireSpaceAdmin/requireSpaceDelete for the space-wide // exposure-policy and delete/restore operations. A require* helper resolves the space by id and @@ -137,7 +137,7 @@ func (p *Plugin) requireSpaceRead(w http.ResponseWriter, spaceID, userID string) if !ok { return nil, app.ReadDenied, false } - resolution, ok := p.resolveSpaceReadOrDeny(w, "api.space.read", space, userID) + resolution, ok := p.requireSpaceReadFrom(w, "api.space.read", space, userID) if !ok { return nil, app.ReadDenied, false } @@ -145,18 +145,18 @@ func (p *Plugin) requireSpaceRead(w http.ResponseWriter, spaceID, userID string) } // requireSpacePagePerm gates a route on a single page-scoped permission (read-only; write -// permissions go through gatePageWrite so the auto-join pre-step can run first). +// permissions go through requirePageWrite so the auto-join pre-step can run first). func (p *Plugin) requireSpacePagePerm(w http.ResponseWriter, spaceID, userID string, perm *mmmodel.Permission) (*model.Space, bool) { return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { return p.service.RequireSpacePagePermission("api.space.page", space, userID, perm) }) } -// requireSpaceManage gates a route on Service.RequireSpaceManage: sysadmin, channel admin_space, or +// requireSpaceManage gates a route on manage-tier authority: sysadmin, channel admin_space, or // (once the read resolver has already admitted the caller) team manage_space. func (p *Plugin) requireSpaceManage(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { - return p.service.RequireSpaceManage("api.space.manage", space, userID) + return p.service.RequireSpaceAdminOrTeamPerm("api.space.manage", space, userID, mmmodel.PermissionManageSpace) }) } @@ -174,25 +174,25 @@ func (p *Plugin) requireSpaceAdmin(w http.ResponseWriter, spaceID, userID string // gate then evaluate against that soft-deleted record. func (p *Plugin) requireSpaceDelete(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { return p.requireSpaceGate(w, spaceID, includeDeleted, func(space *model.Space) *mmmodel.AppError { - return p.service.RequireSpaceDeleteAuthority("api.space.delete", space, userID) + return p.service.RequireSpaceAdminOrTeamPerm("api.space.delete", space, userID, mmmodel.PermissionDeleteSpace) }) } -// gatePageWrite resolves the read gate first — a caller cannot be granted write authority over a +// requirePageWrite resolves the read gate first — a caller cannot be granted write authority over a // space it cannot read — then runs the auto-join pre-step when that read was admitted only via the // non-member open-space fall-through, then re-resolves perm as a (possibly just-joined) member. // Writes the error response and returns false on any denial. -func (p *Plugin) gatePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission) bool { - resolution, ok := p.resolveSpaceReadOrDeny(w, "gatePageWrite", space, userID) +func (p *Plugin) requirePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission) bool { + resolution, ok := p.requireSpaceReadFrom(w, "requirePageWrite", space, userID) if !ok { return false } - return p.gatePageWriteFrom(w, space, userID, perm, resolution) + return p.requirePageWriteFrom(w, space, userID, perm, resolution) } -// gatePageWriteFrom is gatePageWrite for a caller that has already resolved the read gate for the +// requirePageWriteFrom is requirePageWrite for a caller that has already resolved the read gate for the // same space and user, so that resolution is not derived a second time. -func (p *Plugin) gatePageWriteFrom(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, resolution app.ReadResolution) bool { +func (p *Plugin) requirePageWriteFrom(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, resolution app.ReadResolution) bool { if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, nil); appErr != nil { p.writeAppError(w, appErr) return false @@ -204,26 +204,26 @@ func (p *Plugin) gatePageWriteFrom(w http.ResponseWriter, space *model.Space, us return true } -// gateDeleteOwnOrAny gates a delete-class page operation: delete_page (any), or delete_own_page +// requireDeleteOwnOrAnyFrom gates a delete-class page operation: delete_page (any), or delete_own_page // when ownerID == userID. The auto-join pre-step runs against delete_own_page, gated on // ownership, since only that path can admit a non-member write. resolution is the read gate the // caller has already resolved for the same space and user, so it is not re-derived here. -func (p *Plugin) gateDeleteOwnOrAny(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) bool { +func (p *Plugin) requireDeleteOwnOrAnyFrom(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) bool { if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }); appErr != nil { p.writeAppError(w, appErr) return false } - _, ok := p.resolveOwnOrAnyOrDeny(w, "gateDeleteOwnOrAny", space, userID, + _, ok := p.requireOwnOrAnyFrom(w, "requireDeleteOwnOrAnyFrom", space, userID, "api.page.delete", mmmodel.PermissionDeletePage, "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) return ok } -// resolveOwnOrAnyOrDeny resolves a two-tier own/any permission pair against an already-resolved +// requireOwnOrAnyFrom resolves a two-tier own/any permission pair against an already-resolved // read, mapping a denial to the shared existence-hiding 403 under where. It reports whether the // caller qualified only via ownPerm, which a caller that must push ownership enforcement further // down needs. Writes the error response and returns ok=false on failure. -func (p *Plugin) resolveOwnOrAnyOrDeny(w http.ResponseWriter, where string, space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, resolution app.ReadResolution) (ownOnly, ok bool) { +func (p *Plugin) requireOwnOrAnyFrom(w http.ResponseWriter, where string, space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, resolution app.ReadResolution) (ownOnly, ok bool) { ownOnly, allowed, permErr := p.service.ResolveSpacePageOwnOrAny(space, userID, anyWhere, anyPerm, ownWhere, ownPerm, ownerMatches, resolution) if permErr != nil { p.writeAppError(w, permErr) @@ -236,10 +236,10 @@ func (p *Plugin) resolveOwnOrAnyOrDeny(w http.ResponseWriter, where string, spac return ownOnly, true } -// resolveSpaceReadOrDeny resolves the read gate that precedes every page read and page-write gate, +// requireSpaceReadFrom resolves the read gate that precedes every page read and page-write gate, // mapping a denied read to the shared existence-hiding 403. Writes the error response and returns // ok=false on failure. -func (p *Plugin) resolveSpaceReadOrDeny(w http.ResponseWriter, where string, space *model.Space, userID string) (app.ReadResolution, bool) { +func (p *Plugin) requireSpaceReadFrom(w http.ResponseWriter, where string, space *model.Space, userID string) (app.ReadResolution, bool) { resolution, resErr := p.service.ResolveSpaceRead(where, space, userID) if resErr != nil { p.writeAppError(w, resErr) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index b8049cb..76b8f90 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -712,7 +712,7 @@ func TestHandler_UpdatePage_BaselineRequired(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) require.Equal(t, "app.optimistic_lock.baseline_required.app_error", appErr.Id) - // force substitutes for the baseline (last-write-wins). + // force substitutes for the baseline, so the update applies over the stored row. rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, map[string]any{ "body": `{"type":"doc","content":[]}`, "search_text": "text", @@ -1196,7 +1196,7 @@ func TestHandler_RestorePage_WrongSpaceIs404(t *testing.T) { // TestHandler_SpaceMembershipRequired verifies that all space- and page-scoped handlers // reject callers who are not members of a private space's backing channel with 403 Forbidden. -// Private, not the default open fixture: an open space deliberately admits non-member reads +// Private, not the default open fixture: an open space deliberately allows non-member reads // and auto-joins non-member default-granted writes, so this membership-required // assertion only holds on a private space. func TestHandler_SpaceMembershipRequired(t *testing.T) { @@ -2267,7 +2267,7 @@ func TestHandler_UpdateSpace_ViewAccessRequiresAdmin(t *testing.T) { } // TestHandler_UpdateSpace_ViewAccessForceRejected verifies force=true is rejected on a ViewAccess -// change: the exposure-policy escalation check must always run, never last-write-wins. +// change: the exposure-policy escalation check must always run, never be skipped by force. func TestHandler_UpdateSpace_ViewAccessForceRejected(t *testing.T) { mockAPI := newEnabledMockAPI() userID := mmmodel.NewId() diff --git a/server/api_page.go b/server/api_page.go index c46a613..d84aecb 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -23,13 +23,13 @@ const maxPageBodyBytes = 8 << 20 // 8 MiB // IDs, booleans, and timestamps — no content fields. const maxPageStructBodyBytes = 4 * 1024 // 4 KiB -// resolveTargetSpaceRead validates a target_space_id carried in the request body and resolves the +// requireTargetSpaceRead validates a target_space_id carried in the request body and resolves the // caller's read access to it. Unlike the {space_id} in the URL path — which every handler checks // up front — a body-supplied ID only surfaces after decoding, so it is checked inline here. It // writes the error response and returns ok=false on failure. Callers pass the invalid-ID // rejection as a pre-built AppError with a string-literal ID so the i18n extraction tool can // discover the message key. -func (p *Plugin) resolveTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, app.ReadResolution, bool) { +func (p *Plugin) requireTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, app.ReadResolution, bool) { if !mmmodel.IsValidId(targetSpaceID) { p.writeAppError(w, invalidIDErr) return nil, app.ReadDenied, false @@ -45,7 +45,7 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.gatePageWrite(w, space, userID, mmmodel.PermissionCreatePage) { + if !p.requirePageWrite(w, space, userID, mmmodel.PermissionCreatePage) { return } @@ -90,7 +90,7 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage) { + if !p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) { return } @@ -129,7 +129,7 @@ func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId, read) { + if !p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) { return } if appErr := p.service.DeletePage(vars["page_id"], vars["space_id"], userID); appErr != nil { @@ -153,7 +153,7 @@ func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - if !p.gateDeleteOwnOrAny(w, space, userID, page.UserId, read) { + if !p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) { return } restored, appErr := p.service.RestorePage(vars["page_id"], vars["space_id"], userID) @@ -176,7 +176,7 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.gatePageWrite(w, space, userID, mmmodel.PermissionEditPage) { + if !p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) { return } @@ -223,12 +223,12 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { targetSpace, targetRead := sourceSpace, sourceRead if req.TargetSpaceId != "" && req.TargetSpaceId != vars["space_id"] { var targetOK bool - targetSpace, targetRead, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + targetSpace, targetRead, targetOK = p.requireTargetSpaceRead(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) if !targetOK { return } } - if !p.gatePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { + if !p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { return } @@ -288,7 +288,7 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { targetSpace, targetRead := sourceSpace, sourceRead if req.TargetSpaceId != vars["space_id"] { var targetOK bool - targetSpace, targetRead, targetOK = p.resolveTargetSpaceRead(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + targetSpace, targetRead, targetOK = p.requireTargetSpaceRead(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) if !targetOK { return } @@ -296,10 +296,10 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { // Source-side remove-class gate: delete_page (any) if held, else delete_own_page, which // requires the caller own every page in the moved subtree (passed down as requiredOwnerID). // No source auto-join — - // only the target side admits a non-member write. Resolved before the target gate below, which + // only the target side allows a non-member write. Resolved before the target gate below, which // can join the caller to the target space: a caller denied here must not be left holding a // membership the rejected request created. - ownOnly, ok := p.resolveOwnOrAnyOrDeny(w, "handleMovePageToSpace", sourceSpace, userID, + ownOnly, ok := p.requireOwnOrAnyFrom(w, "handleMovePageToSpace", sourceSpace, userID, "api.page.move_to_space", mmmodel.PermissionDeletePage, "api.page.move_to_space.own", mmmodel.PermissionDeleteOwnPage, true, sourceRead) if !ok { @@ -310,7 +310,7 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { requiredOwnerID = userID } - if !p.gatePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { + if !p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { return } diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 686de5a..6214274 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -156,17 +156,15 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID } // MovePageToSpace moves a page and its subtree to another space in the same team. parentPageID -// nil/"" places it at the target root. Rejects cross-team moves and wrong-space parents here; -// destination-inside-subtree cycles and depth-cap breaches are enforced authoritatively by -// store.MovePageToSpace and surface through storeAppError's shared message keys. +// nil/"" places it at the target root. Cross-team moves, wrong-space parents, +// destination-inside-subtree cycles, and depth-cap breaches are all rejected. // A nil expectedUpdateAt without force is rejected: the mutation must supply a baseline. // sourceSpace and targetSpace are the caller's already-fetched records (from its membership // gates), so no re-read happens here. userID is the acting user, recorded in logs only — a // move does not change the page's LastModifiedBy. requiredOwnerID, when non-empty, requires // every live page in the moved subtree to be owned by it — the gate resolves this to userID on -// the delete_own_page-only path and "" on the delete_page (any) path. store.MovePageToSpace -// re-checks this against the exact subtree it moves, so the whole move is rejected if any page -// has a different owner. A same-space request +// the delete_own_page-only path and "" on the delete_page (any) path — and the move is rejected +// as a whole if any page has a different owner. A same-space request // requires only the reparented root to be owned, since no other page leaves the space. // Per-page restrictions and redirects are not handled yet. func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID, requiredOwnerID string) (*model.Page, *mmmodel.AppError) { diff --git a/server/app/permissions.go b/server/app/permissions.go index 8d204af..3d50e23 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -49,10 +49,8 @@ func (s *Service) openTeamFallthrough(userID, teamID string) bool { return s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadPublicChannel) && s.complianceModeOff() } -// readResolutionFrom evaluates the read gate from an already-resolved sysadmin/active pair, so a -// caller that has resolved them once (requireActiveMemberGate) never re-derives them: sysadmin -// override, then backing-channel membership, then — for an open space — the non-member team -// fall-through. +// readResolutionFrom evaluates the read gate against space for userID, taking sysadmin and active +// pre-resolved so a caller that already ran requireActiveMemberGate does not derive them twice. func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, userID string) ReadResolution { if sysadmin { return ReadViaSysadmin @@ -80,10 +78,10 @@ func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID stri return s.readResolutionFrom(sysadmin, active, space, userID), nil } -// requireActiveMemberGate runs the four-gate preamble shared by RequireSpacePagePermission, -// requireChannelAdminOrTeamPerm, and RequireSpaceAdminOrSysadmin (RequireSpacePagePermissionFrom -// skips it, reusing a ReadResolution its caller already resolved): client wiring, -// existence-hiding on a nil space, the sysadmin override, and +// requireActiveMemberGate performs the four checks that precede the permission-specific branches +// of RequireSpacePagePermission, RequireSpaceAdminOrTeamPerm, and RequireSpaceAdminOrSysadmin +// (RequireSpacePagePermissionFrom skips them, reusing a ReadResolution its caller already +// resolved): client wiring, existence-hiding on a nil space, the sysadmin override, and // active-team-membership resolution with its 500 on a genuine lookup failure. A non-nil appErr // must be returned by the caller immediately. Otherwise, when sysadmin is true the caller may // return nil immediately; when it is false the caller continues with active to evaluate its own @@ -161,13 +159,21 @@ func (s *Service) RequireSpacePagePermissionFrom(where string, space *model.Spac return s.evaluatePagePermission(where, space, userID, perm, true) } -// ResolveSpacePageOwnOrAny evaluates a two-tier own/any permission pair: anyPerm if held, else -// ownPerm when ownerMatches. Reports whether the caller qualified only via ownPerm (ownOnly), so -// a caller that must push ownership enforcement further down — MovePageToSpace's subtree-wide -// check — can tell the two tiers apart. admitted=false with a nil appErr means neither tier -// admitted the caller; the caller writes its own denial so the operation label stays its own. A -// non-nil appErr is a genuine backend failure from the check itself, which the caller must -// surface as-is rather than reporting as a denial. +// ResolveSpacePageOwnOrAny decides a page operation that is granted by either of two permissions: +// a broad one covering any page, or a narrower own-page one that applies when the caller owns the +// target. delete_page with delete_own_page is the pair; move-to-space's remove-class gate is the +// other. The caller is admitted by anyPerm alone, or by ownPerm when ownerMatches. Each of the two +// attempts has to tell a denial apart from a failed lookup, which is why the sequence lives here +// instead of being repeated per handler. +// +// ownOnly reports that ownPerm was what admitted the caller. It matters where the operation +// reaches further than the one page whose owner was checked here: MovePageToSpace relocates an +// entire subtree, so an own-admitted caller must additionally be shown to own every page in it. +// +// admitted=false with a nil appErr means neither permission grants the operation. The caller +// writes its own denial, so the 403 carries the caller's operation label rather than this one's. +// A non-nil appErr is a failure of the check itself, not a denial: reporting it as a denial would +// present a backend outage to the user as "not authorized". func (s *Service) ResolveSpacePageOwnOrAny(space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, admittedVia ReadResolution) (ownOnly, admitted bool, appErr *mmmodel.AppError) { anyErr := s.RequireSpacePagePermissionFrom(anyWhere, space, userID, anyPerm, admittedVia) if anyErr == nil { @@ -189,11 +195,12 @@ func (s *Service) ResolveSpacePageOwnOrAny(space *model.Space, userID, anyWhere return false, false, nil } -// requireChannelAdminOrTeamPerm gates an elevated space operation: sysadmin, channel admin_space +// RequireSpaceAdminOrTeamPerm gates an elevated space operation: sysadmin, channel admin_space // (plus active team membership), or teamPerm on the space's team — the latter only once the read // resolver has already admitted the caller, so a team-wide grant authorizes acting only on spaces -// the caller can already read. -func (s *Service) requireChannelAdminOrTeamPerm(where string, space *model.Space, userID string, teamPerm *mmmodel.Permission) *mmmodel.AppError { +// the caller can already read. Callers pass the operation's own team permission: manage_space for +// the manage tier, delete_space for delete/restore. +func (s *Service) RequireSpaceAdminOrTeamPerm(where string, space *model.Space, userID string, teamPerm *mmmodel.Permission) *mmmodel.AppError { active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) if appErr != nil { return appErr @@ -211,12 +218,6 @@ func (s *Service) requireChannelAdminOrTeamPerm(where string, space *model.Space return existenceHidingForbidden(where) } -// RequireSpaceManage gates a manage-tier space operation: sysadmin, channel admin_space, or team -// manage_space. -func (s *Service) RequireSpaceManage(where string, space *model.Space, userID string) *mmmodel.AppError { - return s.requireChannelAdminOrTeamPerm(where, space, userID, mmmodel.PermissionManageSpace) -} - // RequireSpaceAdminOrSysadmin gates the space-wide exposure-policy knobs (ViewAccess, default // capabilities) and admin-affecting member changes: sysadmin, or channel admin_space plus active // team membership. No team-manage_space branch — those knobs are stricter than ordinary manage. @@ -234,12 +235,6 @@ func (s *Service) RequireSpaceAdminOrSysadmin(where string, space *model.Space, return existenceHidingForbidden(where) } -// RequireSpaceDeleteAuthority gates space delete/restore: sysadmin, channel admin_space, or team -// delete_space. -func (s *Service) RequireSpaceDeleteAuthority(where string, space *model.Space, userID string) *mmmodel.AppError { - return s.requireChannelAdminOrTeamPerm(where, space, userID, mmmodel.PermissionDeleteSpace) -} - // WouldDefaultGrant reports whether space's current default capability set (the scheme's // generated user role) grants perm to a plain member — the auto-join admission test. Channel // without a scheme (ErrNotFound) reports false, not an error. @@ -327,7 +322,7 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad return nil }) if lockErr != nil { - return false, lockAppError("AutoJoinIfDefaultGranted", lockErr) + return false, membershipLockAppError("AutoJoinIfDefaultGranted", lockErr) } // Published after the lock is released: the membership lock holds a dedicated connection, so a // slow publish inside it would push concurrent membership mutations into a lock timeout. diff --git a/server/app/service.go b/server/app/service.go index 85b3244..a8ca305 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -105,11 +105,11 @@ func requireBaseline(where, field string, baseline *int64, force bool) *mmmodel. return nil } -// lockAppError maps a WithSpaceMembershipLock failure to an *AppError: an AppError the locked -// closure returned is surfaced as-is, and the store's own errors — notably the retryable +// membershipLockAppError maps a WithSpaceMembershipLock failure to an *AppError: an AppError the +// locked closure returned is surfaced as-is, and the store's own errors — notably the retryable // ErrConflict a lock-acquisition timeout yields — go through storeAppError so they keep their // conventional status codes rather than collapsing to a 500. -func lockAppError(where string, lockErr error) *mmmodel.AppError { +func membershipLockAppError(where string, lockErr error) *mmmodel.AppError { var appErr *mmmodel.AppError if errors.As(lockErr, &appErr) { return appErr diff --git a/server/app/space.go b/server/app/space.go index 46f4c4a..ec273fd 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -499,7 +499,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, return nil }) if lockErr != nil { - return nil, lockAppError("SetSpaceDefaultCapabilities", lockErr) + return nil, membershipLockAppError("SetSpaceDefaultCapabilities", lockErr) } fresh, getErr := s.GetSpace(space.Id) @@ -590,10 +590,10 @@ func normalizeAndValidateSpacePatch(where string, patch *model.SpacePatch) *mmmo // UpdateSpace applies the non-nil fields of patch onto the space and saves it. A non-nil // field (including an empty string) overwrites the current value, so a field can be cleared. // Optimistic-locked on expectedUpdateAt: the caller passes the UpdateAt it last read, and a stale -// baseline yields a conflict unless force overrides it with last-write-wins; a nil -// expectedUpdateAt without force is rejected. The store merges the patch into the current row, -// so a forced update overwrites only the fields the patch supplies — concurrent changes to -// other fields survive. space is the caller's already-fetched record (from its +// baseline yields a conflict unless force overrides the check and applies the update anyway; a +// nil expectedUpdateAt without force is rejected. The store merges the patch into the current +// row, so a forced update overwrites only the fields the patch supplies — concurrent changes to +// other fields are preserved. space is the caller's already-fetched record (from its // membership gate); only its Id is used here. // // A patch that changes ViewAccess requires RequireSpaceAdminOrSysadmin against the live row and is @@ -684,7 +684,7 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec return nil }) if lockErr != nil { - return nil, lockAppError("UpdateSpace", lockErr) + return nil, membershipLockAppError("UpdateSpace", lockErr) } return result, nil } diff --git a/server/app/space_members.go b/server/app/space_members.go index 73f68bd..17f2f75 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -122,7 +122,8 @@ func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID stri } // SetSpaceMemberCapabilities replaces targetUserID's per-member granted capability set. Callers -// must already hold manage-tier authority over the space (see RequireSpaceManage); this method +// must already hold manage-tier authority over the space (RequireSpaceAdminOrTeamPerm with +// manage_space); this method // additionally enforces the self/admin escalation guard and the last-admin invariant. Guest // members are rejected: they stay read-only via the scheme's guest role. actingUserID is the // caller, used only to decide the self-escalation guard. diff --git a/server/app/ws_events.go b/server/app/ws_events.go index dc1e7bf..017f676 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -12,7 +12,7 @@ import ( // "custom__" to each name on the wire, so the names carry no redundant plugin prefix. // // Every event is scoped to the space's backing channel. Reads are not purely -// membership-gated (an open space also admits non-member reads via the team read_public_channel +// membership-gated (an open space also allows non-member reads via the team read_public_channel // fall-through, and the team space list includes open spaces the caller hasn't joined), but WS // delivery deliberately stays channel-scoped: a non-member reader of an open space receives no // live updates until a write auto-joins them. This is narrower than the read set — an accepted diff --git a/server/e2e/container_test.go b/server/e2e/container_test.go index 5c82aba..1a09060 100644 --- a/server/e2e/container_test.go +++ b/server/e2e/container_test.go @@ -160,8 +160,9 @@ func waitForPhase2Migration(ctx context.Context, adminClient *mmmodel.Client4) e // resolveBundlePath globs dist/ (relative to this package's directory) for the built plugin // bundle, failing with a clear pointer to `make dist` when it is absent. Several bundles can sit -// there (a version bump, or a stale host-only bundle from `make server`), so the newest by -// modification time wins — the same one `make test-e2e` inspects before deciding to rebuild. +// there (a version bump, or a stale host-only bundle from `make server`), so the one with the +// newest modification time is selected — the same one `make test-e2e` inspects before deciding +// to rebuild. func resolveBundlePath() (string, error) { matches, err := filepath.Glob("../../dist/" + pluginID + "-*.tar.gz") if err != nil { diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index dc9e96c..c5a87e4 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -24,8 +24,8 @@ var ( CapabilityCommentPage = mmmodel.PermissionCommentPage.Id CapabilityEditPage = mmmodel.PermissionEditPage.Id CapabilityDeleteOwnPage = mmmodel.PermissionDeleteOwnPage.Id - // CapabilityDeletePage (delete-any) is never independently grantable — it rides only the - // admin capability (SchemeAdmin), never an ExplicitRoles atomic role. + // CapabilityDeletePage (delete-any) is never independently grantable — it is granted only by + // the admin capability (SchemeAdmin), never by an ExplicitRoles atomic role. CapabilityDeletePage = mmmodel.PermissionDeletePage.Id // CapabilityAdminSpace is the admin capability: a member-grant target (toggles SchemeAdmin), // but never a valid space-default capability. diff --git a/server/model/space_capabilities_test.go b/server/model/space_capabilities_test.go index 4ed327f..bdd0784 100644 --- a/server/model/space_capabilities_test.go +++ b/server/model/space_capabilities_test.go @@ -25,8 +25,8 @@ var grantableCapabilities = []string{ } // TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip verifies that every subset of the -// grantable capabilities survives RolesForCapabilities -> (core persistence, which drops the base -// scheme-user token and keeps only the atomic role names) -> CapabilitiesFromMember unchanged. +// grantable capabilities is unchanged after RolesForCapabilities -> (core persistence, which +// drops the base scheme-user token and keeps only the atomic role names) -> CapabilitiesFromMember. func TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip(t *testing.T) { const schemeUserRole = "generated_scheme_user_role" diff --git a/server/store/page_store.go b/server/store/page_store.go index 8e396b3..6dad14b 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -166,7 +166,7 @@ func (s *Store) GetPage(pageID string, includeDeleted bool) (*model.Page, error) // When force is false it compare-and-swaps on EditAt, returning ErrConflict if a concurrent // writer has advanced it. When force is true the CAS is skipped, but the patch is still merged // into the current row, so fields the patch leaves untouched keep any concurrent edit rather -// than being clobbered by a stale snapshot. lastModifiedBy records the editor. +// than being overwritten by a stale snapshot. lastModifiedBy records the editor. func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseEditAt int64, force bool, lastModifiedBy string) (_ *model.Page, err error) { if pageID == "" { return nil, &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} diff --git a/server/store/space_store.go b/server/store/space_store.go index 3268442..a5271fd 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -123,10 +123,10 @@ func (s *Store) GetSpacesForTeam(teamID, userID string, callerHasOpenFallthrough // UpdateSpace applies patch to a live space's mutable fields (Title, Description, Icon, // Props). The patch is merged into the row read under lock, so fields the patch leaves nil -// keep any concurrent writer's value rather than being clobbered by the caller's stale -// snapshot. expectedUpdateAt is the optimistic-lock baseline (first-one-wins): a mismatch -// returns ErrConflict. Passing force skips the check (last-write-wins), but the merge into -// the locked row still applies. +// keep any concurrent writer's value rather than being overwritten by the caller's stale +// snapshot. expectedUpdateAt is the optimistic-lock baseline: a mismatch returns ErrConflict, +// so the first update to commit is the one kept. Passing force skips that check and applies the +// caller's update over whatever is stored, but the merge into the locked row still applies. func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpdateAt int64, force bool) (_ *model.Space, err error) { if spaceID == "" { return nil, &ErrInvalidInput{Entity: "Space", Field: "Id", Value: spaceID} diff --git a/server/store/store_test.go b/server/store/store_test.go index eb7c063..a532ad0 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -655,7 +655,7 @@ func TestUpdateSpaceForceAndDefault(t *testing.T) { saved, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - // A missing baseline (UpdateAt == 0) must conflict by default, not last-write-wins. + // A missing baseline (UpdateAt == 0) must conflict by default, not overwrite the stored row. noBaselineTitle := "No baseline" _, conflictErr := s.UpdateSpace(saved.Id, &model.SpacePatch{Title: &noBaselineTitle}, 0, false) require.Error(t, conflictErr) @@ -670,7 +670,7 @@ func TestUpdateSpaceForceAndDefault(t *testing.T) { // TestUpdateSpaceForceMergesPatch verifies that a forced update merges the patch into the row // read under lock: fields the patch leaves nil keep a concurrent writer's value instead of -// being clobbered by the forcing caller's stale snapshot. +// being overwritten by the forcing caller's stale snapshot. func TestUpdateSpaceForceMergesPatch(t *testing.T) { s := openTestDB(t) @@ -686,7 +686,7 @@ func TestUpdateSpaceForceMergesPatch(t *testing.T) { forced, forceErr := s.UpdateSpace(saved.Id, &model.SpacePatch{Title: &title}, saved.UpdateAt, true) require.NoError(t, forceErr) require.Equal(t, "forced title", forced.Title) - require.Equal(t, "concurrent description", forced.Description, "force must not clobber fields the patch omits") + require.Equal(t, "concurrent description", forced.Description, "force must not overwrite fields the patch omits") require.Greater(t, forced.UpdateAt, afterConcurrent.UpdateAt) } @@ -1281,7 +1281,7 @@ func TestRestorePageAppendsAtEndOfSiblingGroup(t *testing.T) { } // TestDeleteRestoreAdvancesEditAt verifies the CAS token (EditAt) advances across a delete+restore -// cycle, so a client holding the pre-delete token still hits a conflict instead of clobbering state. +// cycle, so a client holding the pre-delete token still hits a conflict instead of overwriting state. func TestDeleteRestoreAdvancesEditAt(t *testing.T) { s := openTestDB(t) @@ -1552,7 +1552,7 @@ func TestUpdatePageForceConcurrentMonotonic(t *testing.T) { // TestUpdatePageForcePreservesUnpatchedFields verifies that a force save merges the patch into // the live row under the lock, so a field the patch leaves nil keeps a concurrent edit instead -// of being clobbered by a stale snapshot. A title-only force save must not revert a body edit +// of being overwritten by a stale snapshot. A title-only force save must not revert a body edit // that landed after the caller's baseEditAt. func TestUpdatePageForcePreservesUnpatchedFields(t *testing.T) { s := openTestDB(t) @@ -1577,7 +1577,7 @@ func TestUpdatePageForcePreservesUnpatchedFields(t *testing.T) { forced, err := s.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Title: &forcedTitle}, created.EditAt, true, userID) require.NoError(t, err) require.Equal(t, forcedTitle, forced.Title) - require.Equal(t, concurrentBody, forced.Body, "force save must not clobber the concurrent body edit") + require.Equal(t, concurrentBody, forced.Body, "force save must not overwrite the concurrent body edit") // Confirm it persisted, not just the returned struct. persisted, err := s.GetPage(created.Id, false) From 29bd660ddc0afc90cf5c019139fa6e1523c89a3d Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 18:02:31 +0200 Subject: [PATCH 15/28] Gate draft and presence routes on space page capabilities --- server/api.go | 15 ++++++ server/api_handler_test.go | 95 +++++++++++++++++++++++++++++++++++ server/api_page_drafts.go | 15 ++++-- server/app/page_draft.go | 14 +++++- server/app/page_draft_test.go | 80 ++++++++++++++--------------- server/app/permissions.go | 34 +++++++++++++ server/app/service_test.go | 24 +++++++-- server/app/ws_events_test.go | 14 +++--- 8 files changed, 235 insertions(+), 56 deletions(-) diff --git a/server/api.go b/server/api.go index 19d4f04..2c8c11d 100644 --- a/server/api.go +++ b/server/api.go @@ -231,6 +231,21 @@ func (p *Plugin) requireDeleteOwnOrAnyFrom(w http.ResponseWriter, space *model.S return ok } +// requireDraftWrite gates a draft mutation on create-or-edit authority over the space, resolving +// the read gate first like every other write. It returns the space so the caller can pass the +// backing channel on to the service. +func (p *Plugin) requireDraftWrite(w http.ResponseWriter, spaceID, userID string) (*model.Space, app.ReadResolution, bool) { + space, resolution, ok := p.requireSpaceRead(w, spaceID, userID) + if !ok { + return nil, app.ReadDenied, false + } + if appErr := p.service.RequireSpaceDraftWrite("api.page_draft.write", space, userID, resolution); appErr != nil { + p.writeAppError(w, appErr) + return nil, app.ReadDenied, false + } + return space, resolution, true +} + // requireOwnOrAnyFrom resolves a two-tier own/any permission pair against an already-resolved // read, mapping a denial to the shared existence-hiding 403 under where. It reports whether the // caller qualified only via ownPerm, which a caller that must push ownership enforcement further diff --git a/server/api_handler_test.go b/server/api_handler_test.go index d4e686f..be37c93 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -2422,3 +2422,98 @@ func TestHandler_OpenSpaceReadFallthrough_HiddenWithoutReadPublicChannel(t *test rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, caller, nil) require.Equal(t, http.StatusForbidden, rec.Code) } + +// TestHandler_DraftWritesRequireContributeCapability verifies the draft-write gate: a member who +// can read the space but holds neither create_page nor edit_page may still read their own drafts +// and the presence snapshot, but cannot autosave, reserve a new page id, or publish. A draft is a +// pending page, so holding one at all requires the authority to contribute pages here. +func TestHandler_DraftWritesRequireContributeCapability(t *testing.T) { + mockAPI := newEnabledMockAPI() + channelID := mmmodel.NewId() + reader := mmmodel.NewId() + + // Registered before openTestPlugin so these deny-stubs win over StubDefaultSpacePermissions' + // permissive catch-alls (mock matching is by registration order). + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), reader).Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("HasPermissionToChannel", reader, channelID, mmmodel.PermissionReadPage).Return(true) + mockAPI.On("HasPermissionToChannel", reader, channelID, mmmodel.PermissionCreatePage).Return(false) + mockAPI.On("HasPermissionToChannel", reader, channelID, mmmodel.PermissionEditPage).Return(false) + + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + page := seedPage(t, h.store, space.Id, channelID, "") + testutil.MustAddChannelMember(t, h.db, channelID, reader) + + // Seeded through the store, not the API: the publish gate must be what rejects the request, + // and without an existing draft publish would 404 on its idempotency guard before reaching it. + _, _, err := h.store.UpsertDraft(&model.Draft{ + UserId: reader, SpaceId: space.Id, PageId: page.Id, Title: "Pending", BaseEditAt: page.EditAt, + }, nil, nil, nil) + require.NoError(t, err) + + denied := []struct { + method string + path string + body any + }{ + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/drafts", map[string]any{"title": "D"}}, + {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", map[string]any{"title": "D"}}, + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft/publish", nil}, + } + for _, tc := range denied { + rec := h.do(t, tc.method, tc.path, reader, tc.body) + require.Equal(t, http.StatusForbidden, rec.Code, "expected 403 for %s %s", tc.method, tc.path) + } + + // Reads stay open to a plain reader: the draft routes are own-scoped in the store, so reading + // carries no exposure beyond the space read the caller already holds. + allowed := []string{ + "/api/v1/spaces/" + space.Id + "/drafts", + "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/active-editors", + } + for _, path := range allowed { + rec := h.do(t, http.MethodGet, path, reader, nil) + require.Equal(t, http.StatusOK, rec.Code, "expected 200 for GET %s", path) + } +} + +// TestHandler_PublishGatesOnTargetKind verifies that publish resolves its permission from the +// target the draft lands on, not from the route: a member holding create_page but not edit_page +// may publish a brand-new page, and is refused when the same call would overwrite a page that is +// already live. The distinction is derived inside PublishPageDraft, where the target row is +// classified. +func TestHandler_PublishGatesOnTargetKind(t *testing.T) { + mockAPI := newEnabledMockAPI() + channelID := mmmodel.NewId() + author := mmmodel.NewId() + + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), author).Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("HasPermissionToChannel", author, channelID, mmmodel.PermissionReadPage).Return(true) + mockAPI.On("HasPermissionToChannel", author, channelID, mmmodel.PermissionCreatePage).Return(true) + mockAPI.On("HasPermissionToChannel", author, channelID, mmmodel.PermissionEditPage).Return(false) + + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + testutil.MustAddChannelMember(t, h.db, channelID, author) + + // New-page path: reserve an id, autosave into it, publish. create_page alone carries this. + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/drafts", author, map[string]any{"title": "New"}) + require.Equal(t, http.StatusCreated, rec.Code) + var reserved model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &reserved)) + + rec = h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+reserved.PageId+"/draft/publish", author, nil) + require.Equal(t, http.StatusCreated, rec.Code, "create_page must carry a new-page publish") + var published model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &published)) + + // Edit path: the same author now has a draft over the page they just published. Publishing it + // updates live content, which needs edit_page. base_edit_at baselines the draft against the + // page just published, so the publish below fails on authority rather than on a stale baseline. + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+reserved.PageId+"/draft", author, + map[string]any{"title": "Revised", "base_edit_at": published.EditAt}) + require.Equal(t, http.StatusOK, rec.Code, "the draft write itself only needs the create-or-edit pair") + + rec = h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+reserved.PageId+"/draft/publish", author, nil) + require.Equal(t, http.StatusForbidden, rec.Code, "publishing over a live page must require edit_page") +} diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index 62e783a..3f68577 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -43,7 +43,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { pageID := vars["page_id"] userID := userIDFromRequest(r) - space, _, ok := p.requireSpaceRead(w, spaceID, userID) + space, _, ok := p.requireDraftWrite(w, spaceID, userID) if !ok { return } @@ -143,7 +143,13 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) spaceID := vars["space_id"] userID := userIDFromRequest(r) - if _, _, ok := p.requireSpaceRead(w, spaceID, userID); !ok { + // Reserving a page id is the first act of page creation, so this gates on create_page rather + // than the looser draft-write pair. + space, resolution, ok := p.requireSpaceRead(w, spaceID, userID) + if !ok { + return + } + if !p.requirePageWriteFrom(w, space, userID, mmmodel.PermissionCreatePage, resolution) { return } @@ -181,7 +187,8 @@ func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) pageID := vars["page_id"] userID := userIDFromRequest(r) - if _, _, ok := p.requireSpaceRead(w, spaceID, userID); !ok { + space, resolution, ok := p.requireSpaceRead(w, spaceID, userID) + if !ok { return } @@ -195,7 +202,7 @@ func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) return } - page, wasCreated, appErr := p.service.PublishPageDraft(userID, spaceID, pageID, req.Force) + page, wasCreated, appErr := p.service.PublishPageDraft(space, userID, pageID, req.Force, resolution) if appErr != nil { if page != nil { p.writeConflictWithPage(w, appErr, page) diff --git a/server/app/page_draft.go b/server/app/page_draft.go index c963d3b..9f40b57 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -289,13 +289,14 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) // - appErr is a 409 edit conflict → page is the current server page (or nil if the re-read failed), // so the caller can surface a diff without a follow-up read; on every other error page is nil. -func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { +func (s *Service) PublishPageDraft(space *model.Space, userID, pageID string, force bool, admittedVia ReadResolution) (*model.Page, bool, *mmmodel.AppError) { if !mmmodel.IsValidId(userID) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } - if !mmmodel.IsValidId(spaceID) { + if space == nil || !mmmodel.IsValidId(space.Id) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_space_id.app_error", nil, "", http.StatusBadRequest) } + spaceID := space.Id if !mmmodel.IsValidId(pageID) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_page_id.app_error", nil, "", http.StatusBadRequest) } @@ -317,6 +318,15 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, targetErr } + // Authorization runs here rather than in the handler because it depends on the classification + // above: publishing a new page needs create_page, publishing over a live one needs edit_page. + // The draft write that produced this content was gated on the looser create-or-edit pair, so + // this is where authority over the specific target is established — before any shared state + // changes. + if permErr := s.RequireSpacePublish("PublishPageDraft", space, userID, admittedVia, isNewPage); permErr != nil { + return nil, false, permErr + } + // 3. Parent guard (new-page path only): a new page's parent must be a published live page; a // draft-only or non-live parent returns 409. The edit path never reparents, so a stale ParentId // carried on the draft must not block a content-only edit-publish. diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index 05367f5..c035303 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -27,16 +27,16 @@ func docWith(text string) string { // publishNewPage creates a new-page draft, autosaves the given body, and publishes it, returning // the live page. It asserts the reserved draft id is preserved through publish. -func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyText string) *model.Page { +func publishNewPage(t *testing.T, h *testHarness, space *model.Space, userID, title, bodyText string) *model.Page { t.Helper() - draft, appErr := h.svc.CreateSpaceDraft(userID, spaceID, title, "") + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, title, "") require.Nil(t, appErr) reservedID := draft.PageId - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: spaceID, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, nil, "") require.Nil(t, appErr) - page, wasCreated, appErr := h.svc.PublishPageDraft(userID, spaceID, reservedID, false) + page, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, reservedID, false, app.ReadViaMember) require.Nil(t, appErr) require.True(t, wasCreated, "publishing a brand-new page must report wasCreated=true") require.Equal(t, reservedID, page.Id, "publish must preserve the reserved draft id") @@ -68,7 +68,7 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Important", "ORIGINAL") + page := publishNewPage(t, h, space, userID, "Important", "ORIGINAL") // Start an edit session whose first (and only) autosave carries the title but no body, exactly // the heartbeat case that previously wiped the page on publish. @@ -78,7 +78,7 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - republished, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + republished, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) require.Contains(t, republished.Body, "ORIGINAL", "publishing a title-only edit must preserve the page body") } @@ -93,7 +93,7 @@ func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + page := publishNewPage(t, h, space, userID, "Doc", "original") // Start an edit session whose only autosave carries the baseline — no Title, no Body. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ @@ -102,7 +102,7 @@ func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - result, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + result, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) require.False(t, wasCreated, "a no-content publish must not report a creation") require.Equal(t, page.Title, result.Title, "the page's title must be unchanged") @@ -121,7 +121,7 @@ func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") // UpsertDraft guards the first edit-draft save: attempting to create a draft for an existing // page without a base_edit_at baseline is rejected at the store layer, so the client must send @@ -137,7 +137,7 @@ func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Contains(t, forced.Body, "v2") } @@ -166,13 +166,13 @@ func TestPublishRejectsNoBaselineOnExistingPageEdit(t *testing.T) { require.NoError(t, err) // Without a baseline, a non-force publish of an edit must be rejected. - _, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + _, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.page_draft.publish.baseline_required.app_error", appErr.Id) // force=true bypasses the missing-baseline guard and publishes the edit. - forced, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.False(t, wasCreated) require.Equal(t, "Edited", forced.Title) @@ -184,7 +184,7 @@ func TestPublishStaleBaselineConflicts(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") staleEditAt := page.EditAt // Start an edit session with a draft baselined at the current EditAt. The draft persists (it is @@ -204,7 +204,7 @@ func TestPublishStaleBaselineConflicts(t *testing.T) { // Publishing the draft against the now-stale baseline must 409, and return the current server // page (the concurrent edit's content + advanced baseline) so the caller can diff without a // follow-up read. - current, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + current, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) require.NotNil(t, current, "edit conflict must return the current server page") @@ -220,7 +220,7 @@ func TestPublishAfterPageDeleteReturns404(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doomed", "x") + page := publishNewPage(t, h, space, userID, "Doomed", "x") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doomed", Body: docWith("y"), BaseEditAt: page.EditAt, @@ -228,7 +228,7 @@ func TestPublishAfterPageDeleteReturns404(t *testing.T) { require.Nil(t, appErr) requireStoreDeletePage(t, h.store, page.Id, space.Id, userID) - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + _, _, appErr = h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusNotFound, appErr.StatusCode) } @@ -278,7 +278,7 @@ func TestPublishPageDraftConcurrentPublishesConverge(t *testing.T) { for i := range results { wg.Go(func() { <-start - p, created, pubErr := h.svc.PublishPageDraft(userID, space.Id, draft.PageId, false) + p, created, pubErr := h.svc.PublishPageDraft(space, userID, draft.PageId, false, app.ReadViaMember) results[i] = result{page: p, created: created, appErr: pubErr} }) } @@ -329,7 +329,7 @@ func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { spaceB := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, spaceA.Id, userID, "Doc", "x") + page := publishNewPage(t, h, spaceA, userID, "Doc", "x") // The page lives in space A; querying its editors through space B must not resolve it. _, appErr := h.svc.GetPageActiveEditors(page.Id, spaceB.Id) @@ -347,7 +347,7 @@ func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + page := publishNewPage(t, h, space, userID, "Doc", "x") // An autosave is the heartbeat; the editor must then appear as active. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ @@ -368,7 +368,7 @@ func TestPublishSetsLastModifiedBy(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + page := publishNewPage(t, h, space, userID, "Doc", "x") require.Equal(t, userID, page.LastModifiedBy, "a page published via draft must record its author as last modifier") } @@ -377,7 +377,7 @@ func TestPublishForceOverridesStaleBaseline(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") staleEditAt := page.EditAt // Start an edit session with a draft baselined at the current EditAt; the draft persists. @@ -393,7 +393,7 @@ func TestPublishForceOverridesStaleBaseline(t *testing.T) { require.Nil(t, appErr) // force=true must override the stale-baseline CAS and win with the draft's content. - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Contains(t, forced.Body, "v3", "force must override a stale baseline") require.Greater(t, forced.EditAt, concurrentPage.EditAt, "a force-publish must still advance the page's EditAt") @@ -404,7 +404,7 @@ func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + page := publishNewPage(t, h, space, userID, "Original title", "original body") baseEditAt := page.EditAt // A title-only edit: the draft carries a new title but no body, baselined at the current EditAt. @@ -421,7 +421,7 @@ func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { // Force-publish the title-only draft: force overrides the stale baseline, but only the title may // be applied. The concurrent body edit must survive rather than be reverted to the pre-lock value. - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "New title", forced.Title, "the draft's title must be applied") require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must not be reverted") @@ -432,7 +432,7 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") baseEditAt := page.EditAt // Edit and publish: the draft is consumed and the page's EditAt advances. @@ -441,7 +441,7 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { BaseEditAt: baseEditAt, }, nil, nil, nil, "") require.Nil(t, appErr) - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + _, _, appErr = h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) // A late autosave carrying the pre-publish baseline must be rejected, not resurrect a phantom @@ -528,7 +528,7 @@ func TestPublishCarriesDraftProps(t *testing.T) { }, nil, nil, &mmmodel.StringInterface{"color": "blue"}, "") require.Nil(t, appErr) - page, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + page, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.True(t, wasCreated) require.Equal(t, "blue", page.Props["color"], "a new page must adopt the draft's props on publish") @@ -538,7 +538,7 @@ func TestPublishCarriesDraftProps(t *testing.T) { UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", BaseEditAt: page.EditAt, }, nil, nil, &mmmodel.StringInterface{"color": "red"}, "") require.Nil(t, appErr) - edited, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + edited, _, appErr := h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "red", edited.Props["color"], "an edit with non-empty draft props must replace the page's props") @@ -547,7 +547,7 @@ func TestPublishCarriesDraftProps(t *testing.T) { UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2"), BaseEditAt: edited.EditAt, }, nil, nil, nil, "") require.Nil(t, appErr) - preserved, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + preserved, _, appErr := h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "red", preserved.Props["color"], "an edit carrying no props must preserve the page's props") require.Contains(t, preserved.Body, "v2") @@ -573,14 +573,14 @@ func TestPublishRejectsForeignSpacePage(t *testing.T) { // userA autosaves content and publishes, so the page becomes live in space A. _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userA, SpaceId: spaceA.Id, PageId: pageID, Title: "A doc", Body: docWith("a content")}, nil, nil, nil, "") require.Nil(t, appErr) - pageA, wasCreated, appErr := h.svc.PublishPageDraft(userA, spaceA.Id, pageID, false) + pageA, wasCreated, appErr := h.svc.PublishPageDraft(spaceA, userA, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.True(t, wasCreated) // userB has no draft for pageID (the cross-space reservation was rejected above), so publishing // from space B must fail with 404 (draft not found). force cannot bypass this. for _, force := range []bool{false, true} { - _, _, appErr = h.svc.PublishPageDraft(userB, spaceB.Id, pageID, force) + _, _, appErr = h.svc.PublishPageDraft(spaceB, userB, pageID, force, app.ReadViaMember) require.NotNil(t, appErr, "cross-space publish (force=%v) must fail", force) require.Equal(t, http.StatusNotFound, appErr.StatusCode, "cross-space publish (force=%v) must be rejected", force) @@ -636,7 +636,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - parent := publishNewPage(t, h, space.Id, userID, "Parent", "p") + parent := publishNewPage(t, h, space, userID, "Parent", "p") // Publish a child under the (live) parent. childDraft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", parent.Id) @@ -644,7 +644,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { childID := childDraft.PageId _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c1")}, nil, nil, nil, "") require.Nil(t, appErr) - child, _, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + child, _, appErr := h.svc.PublishPageDraft(space, userID, childID, false, app.ReadViaMember) require.Nil(t, appErr) // Start an edit session whose draft still carries the (currently live) parent id. @@ -659,7 +659,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { requireStoreDeletePage(t, h.store, parent.Id, space.Id, userID) // A content-only edit-publish must still succeed: the edit path does not reparent. - republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + republished, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, childID, false, app.ReadViaMember) require.Nil(t, appErr, "edit-publish must not be blocked by a stale parent id") require.False(t, wasCreated) require.Contains(t, republished.Body, "c2") @@ -691,7 +691,7 @@ func TestCreateSpaceDraftRejectsForeignDraftParent(t *testing.T) { // userA can, and the child then publishes only after the parent does. child, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Child", parent.PageId) require.Nil(t, appErr) - _, _, appErr = h.svc.PublishPageDraft(userA, space.Id, child.PageId, false) + _, _, appErr = h.svc.PublishPageDraft(space, userA, child.PageId, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode, "child cannot publish under an unpublished parent") } @@ -755,7 +755,7 @@ func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v1")}, nil, nil, nil, "") require.Nil(t, appErr) - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, pageID, false) + _, _, appErr = h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) // Late autosave with no baseline: the draft is gone, so this must be rejected. @@ -903,7 +903,7 @@ func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { userID := mmmodel.NewId() // Publish a live parent in spaceA. - parentInA := publishNewPage(t, h, spaceA.Id, userID, "Parent", "p") + parentInA := publishNewPage(t, h, spaceA, userID, "Parent", "p") // The store rejects a draft whose ParentId points at a page in another space. // To simulate the race (parent was in the same space when editing started, then moved), @@ -920,7 +920,7 @@ func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { // Publishing must reject: the parent lives in a different space than the draft's space. The guard // collapses "cross-space" and "not a live page" into a single parent_unpublished 409 so the // response can't be used to probe page ids in spaces the caller cannot read. - _, _, appErr := h.svc.PublishPageDraft(userID, spaceB.Id, pageID, false) + _, _, appErr := h.svc.PublishPageDraft(spaceB, userID, pageID, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode, "cross-space parent must be rejected: %v", appErr) require.Equal(t, "app.page_draft.publish.parent_unpublished.app_error", appErr.Id) @@ -1018,7 +1018,7 @@ func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + page := publishNewPage(t, h, space, userID, "Original title", "original body") baseEditAt := page.EditAt // A title-only edit draft baselined at the current EditAt. @@ -1035,7 +1035,7 @@ func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { &model.PagePatch{Title: &concurrentTitle, Body: &concurrentBody}, new(baseEditAt), false, userID) require.Nil(t, appErr) - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "Draft title", forced.Title, "the field the draft carries must win under force") require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must keep the concurrent value") diff --git a/server/app/permissions.go b/server/app/permissions.go index 3d50e23..fdaaa89 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -235,6 +235,40 @@ func (s *Service) RequireSpaceAdminOrSysadmin(where string, space *model.Space, return existenceHidingForbidden(where) } +// requirePageWriteFrom runs the auto-join pre-step for perm and then gates on it, the shared tail +// of every page-write authorization on an already-resolved read. +func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID string, perm *mmmodel.Permission, admittedVia ReadResolution) *mmmodel.AppError { + if _, appErr := s.AutoJoinIfDefaultGranted(space, userID, admittedVia, perm, nil); appErr != nil { + return appErr + } + return s.RequireSpacePagePermissionFrom(where, space, userID, perm, admittedVia) +} + +// RequireSpaceDraftWrite gates a draft mutation on the caller holding either page-creation or +// page-edit authority in the space. A draft is a pending page private to its author, so this +// establishes only that the caller may contribute pages here at all; the exact permission the +// content needs is enforced at publish (RequireSpacePublish), the point where the draft becomes +// state other users can see. Checking the looser pair here also keeps autosave off the page-liveness +// lookup that the precise choice would require. +func (s *Service) RequireSpaceDraftWrite(where string, space *model.Space, userID string, admittedVia ReadResolution) *mmmodel.AppError { + if appErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia); appErr == nil { + return nil + } + return s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionEditPage, admittedVia) +} + +// RequireSpacePublish gates publishing a draft on the permission its target actually needs: +// create_page when the draft becomes a new page, edit_page when it updates a live one. isNewPage +// comes from the publish path's own classification of the target row, so the decision is made +// where that fact already exists rather than costing the handler a second lookup. +func (s *Service) RequireSpacePublish(where string, space *model.Space, userID string, admittedVia ReadResolution, isNewPage bool) *mmmodel.AppError { + perm := mmmodel.PermissionEditPage + if isNewPage { + perm = mmmodel.PermissionCreatePage + } + return s.requirePageWriteFrom(where, space, userID, perm, admittedVia) +} + // WouldDefaultGrant reports whether space's current default capability set (the scheme's // generated user role) grants perm to a plain member — the auto-join admission test. Channel // without a scheme (ErrNotFound) reports false, not an error. diff --git a/server/app/service_test.go b/server/app/service_test.go index 2484767..cb7f8f0 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -13,10 +13,12 @@ import ( "strings" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" mmmodel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" @@ -51,9 +53,25 @@ func openTestService(t *testing.T) *testHarness { s, db := testutil.OpenTestStore(t) - // nil pluginapi client: these tests seed data directly through the store and - // never exercise the client, so no mock is needed. - svc := app.New(s, nil, nil) + // A permissive client rather than nil: most tests here seed data through the store and never + // touch it, but the ones that publish a draft go through PublishPageDraft's own permission + // gate, which needs a wired client. The stub grants the ordinary contribute-member set, so a + // test asserting publish behaviour is not also asserting authorization. + mockAPI := &plugintest.API{} + t.Cleanup(func() { mockAPI.AssertExpectations(t) }) + testutil.StubDefaultSpacePermissions(mockAPI) + mockAPI.On("GetChannelMember", mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("GetTeamMember", mock.Anything, mock.Anything).Return(&mmmodel.TeamMember{}, nil).Maybe() + // With a client wired, the WS publishes and channel side-effects these paths perform are no + // longer no-ops. Tests that assert on specific events register their own expectations against + // their own mock. + mockAPI.On("PublishWebSocketEvent", mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")). + Return(mmmodel.ChannelMembers{}, nil).Maybe() + mockAPI.On("DeleteChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil).Maybe() + svc := app.New(s, nil, pluginapi.NewClient(mockAPI, nil)) return &testHarness{svc: svc, store: s, db: db} } diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index e7fc574..f73cc51 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -187,7 +187,7 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, channelID) // Use a published page so UpdatePageDraft takes the channel-broadcast path. - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", @@ -219,7 +219,7 @@ func TestServiceUpdatePageDraft_PresenceBroadcastThrottled(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() space := mustCreateSpace(t, h.store, h.db, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") presenceBroadcasts := func() int { n := 0 @@ -256,7 +256,7 @@ func TestServicePublishPageDraft_PublishesCreatedEvent(t *testing.T) { userID := mmmodel.NewId() space := mustCreateSpace(t, h.store, h.db, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "hello") + page := publishNewPage(t, h, space, userID, "Doc", "hello") mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_created", map[string]any{"page_id": page.Id, "space_id": space.Id, "parent_id": page.ParentId}, @@ -285,7 +285,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() space := mustCreateSpace(t, h.store, h.db, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + page := publishNewPage(t, h, space, userID, "Doc", "original") // Start an edit session against the live page's baseline, then publish it. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ @@ -294,7 +294,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + republished, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) require.False(t, wasCreated, "publishing an edit to an existing page must report wasCreated=false") @@ -328,7 +328,7 @@ func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { space := mustCreateSpace(t, h.store, h.db, channelID) // Use a published page so the edit-draft delete takes the channel-broadcast path. - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", @@ -437,7 +437,7 @@ func TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast(t *te channelID := mmmodel.NewId() userID := mmmodel.NewId() space := mustCreateSpace(t, h.store, h.db, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") // Reset call log so only the two autosaves below are counted. mockAPI.Calls = nil From f746cd2cbb89ef4049b0eeadf19a66db4dc21edc Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 23:58:08 +0200 Subject: [PATCH 16/28] ename method --- server/app/permissions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/app/permissions.go b/server/app/permissions.go index fdaaa89..bf64164 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -269,10 +269,10 @@ func (s *Service) RequireSpacePublish(where string, space *model.Space, userID s return s.requirePageWriteFrom(where, space, userID, perm, admittedVia) } -// WouldDefaultGrant reports whether space's current default capability set (the scheme's +// DefaultRolesGrantPermission reports whether space's current default capability set (the scheme's // generated user role) grants perm to a plain member — the auto-join admission test. Channel // without a scheme (ErrNotFound) reports false, not an error. -func (s *Service) WouldDefaultGrant(space *model.Space, perm *mmmodel.Permission) (bool, error) { +func (s *Service) DefaultRolesGrantPermission(space *model.Space, perm *mmmodel.Permission) (bool, error) { roles, err := s.store.GetSchemeRolesForChannel(space.ChannelId) if err != nil { if store.IsErrNotFound(err) { @@ -328,7 +328,7 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad if resolution != ReadViaOpenFallthrough { return nil // Re-validation failed: e.g. the space flipped private concurrently. } - granted, grantErr := s.WouldDefaultGrant(fresh, perm) + granted, grantErr := s.DefaultRolesGrantPermission(fresh, perm) if grantErr != nil { return grantErr } From 8b6bffeea54d0961143dec43b02ef0fd915dc207 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 29 Jul 2026 13:16:34 +0200 Subject: [PATCH 17/28] renaming helpers --- server/api.go | 10 +++++----- server/api_page.go | 6 +++--- server/api_space.go | 2 +- server/app/page_draft.go | 5 ++--- server/app/permissions.go | 22 +++++++++++----------- server/app/space.go | 2 +- server/app/space_members.go | 12 ++++++------ server/store/scheme_store.go | 4 ++-- server/store/space_store.go | 4 ++-- server/store/store.go | 4 ++-- webapp/i18n/en.json | 3 --- 11 files changed, 35 insertions(+), 39 deletions(-) diff --git a/server/api.go b/server/api.go index 2c8c11d..6579617 100644 --- a/server/api.go +++ b/server/api.go @@ -102,11 +102,11 @@ func (p *Plugin) MattermostAuthorizationRequired(next http.Handler) http.Handler }) } -// fetchSpaceForGate fetches spaceID (with soft-deleted rows included when includeDeleted is +// getSpaceForGate fetches spaceID (with soft-deleted rows included when includeDeleted is // true) and maps a not-found lookup to the shared existence-hiding 403, so no enforcement helper // ever needs to special-case a missing space differently from a denied one. Writes the error // response and returns ok=false on failure. -func (p *Plugin) fetchSpaceForGate(w http.ResponseWriter, spaceID string, includeDeleted bool) (*model.Space, bool) { +func (p *Plugin) getSpaceForGate(w http.ResponseWriter, spaceID string, includeDeleted bool) (*model.Space, bool) { var space *model.Space var appErr *mmmodel.AppError if includeDeleted { @@ -116,7 +116,7 @@ func (p *Plugin) fetchSpaceForGate(w http.ResponseWriter, spaceID string, includ } if appErr != nil { if appErr.StatusCode == http.StatusNotFound { - p.writeAppError(w, mmmodel.NewAppError("fetchSpaceForGate", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(appErr)) + p.writeAppError(w, mmmodel.NewAppError("getSpaceForGate", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(appErr)) return nil, false } p.writeAppError(w, appErr) @@ -129,7 +129,7 @@ func (p *Plugin) fetchSpaceForGate(w http.ResponseWriter, spaceID string, includ // returning ok=false on either a failed fetch or a denied gate. Every space-scoped route gate // below is this shape — only the enforcement helper and its operation label differ. func (p *Plugin) requireSpaceGate(w http.ResponseWriter, spaceID string, includeDeleted bool, gate func(space *model.Space) *mmmodel.AppError) (*model.Space, bool) { - space, ok := p.fetchSpaceForGate(w, spaceID, includeDeleted) + space, ok := p.getSpaceForGate(w, spaceID, includeDeleted) if !ok { return nil, false } @@ -145,7 +145,7 @@ func (p *Plugin) requireSpaceGate(w http.ResponseWriter, spaceID string, include // caller that goes on to evaluate a further permission on the same space can pass the resolution // down instead of re-deriving the team membership behind it. func (p *Plugin) requireSpaceRead(w http.ResponseWriter, spaceID, userID string) (*model.Space, app.ReadResolution, bool) { - space, ok := p.fetchSpaceForGate(w, spaceID, false) + space, ok := p.getSpaceForGate(w, spaceID, false) if !ok { return nil, app.ReadDenied, false } diff --git a/server/api_page.go b/server/api_page.go index 1e42245..bced39e 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -41,7 +41,7 @@ func (p *Plugin) requireTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmm func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - space, ok := p.fetchSpaceForGate(w, vars["space_id"], false) + space, ok := p.getSpaceForGate(w, vars["space_id"], false) if !ok { return } @@ -86,7 +86,7 @@ func (p *Plugin) handleGetPage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - space, ok := p.fetchSpaceForGate(w, vars["space_id"], false) + space, ok := p.getSpaceForGate(w, vars["space_id"], false) if !ok { return } @@ -176,7 +176,7 @@ func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - space, ok := p.fetchSpaceForGate(w, vars["space_id"], false) + space, ok := p.getSpaceForGate(w, vars["space_id"], false) if !ok { return } diff --git a/server/api_space.go b/server/api_space.go index 8b40922..ec61340 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -72,7 +72,7 @@ func (p *Plugin) handleGetSpace(w http.ResponseWriter, r *http.Request) { spaceID := mux.Vars(r)["space_id"] // BuildSpaceWithAccess resolves the read gate itself and returns the same existence-hiding 403 // on a denial, so it is the gate here rather than a second resolution behind requireSpaceRead. - space, ok := p.fetchSpaceForGate(w, spaceID, false) + space, ok := p.getSpaceForGate(w, spaceID, false) if !ok { return } diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 9f40b57..3647cfc 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -320,9 +320,8 @@ func (s *Service) PublishPageDraft(space *model.Space, userID, pageID string, fo // Authorization runs here rather than in the handler because it depends on the classification // above: publishing a new page needs create_page, publishing over a live one needs edit_page. - // The draft write that produced this content was gated on the looser create-or-edit pair, so - // this is where authority over the specific target is established — before any shared state - // changes. + // The draft write that produced this content was gated only on the looser create-or-edit pair, + // so this check must precede the writes below. if permErr := s.RequireSpacePublish("PublishPageDraft", space, userID, admittedVia, isNewPage); permErr != nil { return nil, false, permErr } diff --git a/server/app/permissions.go b/server/app/permissions.go index bf64164..315fe37 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -30,23 +30,23 @@ func existenceHidingForbidden(where string) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden) } -// complianceModeOff reports whether ComplianceSettings.Enable is unset or false. A nil client -// (or nil config) is treated as compliance-off, matching core's own SafeDereference default. -func (s *Service) complianceModeOff() bool { +// isComplianceEnabled reports whether ComplianceSettings.Enable is set and true. A nil client (or +// nil config) reports false, matching core's own SafeDereference default. +func (s *Service) isComplianceEnabled() bool { if s.client == nil { - return true + return false } cfg := s.client.Configuration.GetConfig() if cfg == nil { - return true + return false } - return !mmmodel.SafeDereference(cfg.ComplianceSettings.Enable) + return mmmodel.SafeDereference(cfg.ComplianceSettings.Enable) } -// openTeamFallthrough reports whether userID holds the non-member team read_public_channel +// hasOpenTeamFallthrough reports whether userID holds the non-member team read_public_channel // fall-through into teamID's open spaces. Suppressed under compliance mode. -func (s *Service) openTeamFallthrough(userID, teamID string) bool { - return s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadPublicChannel) && s.complianceModeOff() +func (s *Service) hasOpenTeamFallthrough(userID, teamID string) bool { + return s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadPublicChannel) && !s.isComplianceEnabled() } // readResolutionFrom evaluates the read gate against space for userID, taking sysadmin and active @@ -58,7 +58,7 @@ func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionReadPage) { return ReadViaMember } - if space.ViewAccess == model.ViewAccessOpen && active && s.openTeamFallthrough(userID, space.TeamId) { + if space.ViewAccess == model.ViewAccessOpen && active && s.hasOpenTeamFallthrough(userID, space.TeamId) { return ReadViaOpenFallthrough } return ReadDenied @@ -128,7 +128,7 @@ func (s *Service) evaluatePagePermission(where string, space *model.Space, userI return nil } if perm.Id == mmmodel.PermissionReadPage.Id && space.ViewAccess == model.ViewAccessOpen && active && - s.openTeamFallthrough(userID, space.TeamId) { + s.hasOpenTeamFallthrough(userID, space.TeamId) { return nil } return existenceHidingForbidden(where) diff --git a/server/app/space.go b/server/app/space.go index ec273fd..b689b95 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -550,7 +550,7 @@ func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([] !s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadSpace) { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.forbidden.app_error", nil, "", http.StatusForbidden) } - callerHasOpenFallthrough := active && s.openTeamFallthrough(userID, teamID) + callerHasOpenFallthrough := active && s.hasOpenTeamFallthrough(userID, teamID) spaces, err := s.store.GetSpacesForTeam(teamID, userID, callerHasOpenFallthrough, offset, limit) if err != nil { return nil, false, storeAppError("GetSpacesForTeam", err) diff --git a/server/app/space_members.go b/server/app/space_members.go index 17f2f75..7b355ba 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -40,7 +40,7 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod } members := make([]*model.SpaceMember, 0, len(channelMembers)) for _, cm := range channelMembers { - members = append(members, projectSpaceMember(cm, defaultCaps)) + members = append(members, toSpaceMember(cm, defaultCaps)) } hasMore := false if len(channelMembers) == perPage { @@ -56,8 +56,8 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod return members, hasMore, nil } -// projectSpaceMember builds the wire projection of a channel member's capability state. -func projectSpaceMember(cm *mmmodel.ChannelMember, defaultCaps []string) *model.SpaceMember { +// toSpaceMember builds the wire representation of a channel member's capability state. +func toSpaceMember(cm *mmmodel.ChannelMember, defaultCaps []string) *model.SpaceMember { mc := model.CapabilitiesFromMember(cm.ExplicitRoles, cm.SchemeAdmin, cm.SchemeGuest, defaultCaps) member := &model.SpaceMember{ UserId: cm.UserId, @@ -110,7 +110,7 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": member.UserId}, space.ChannelId) - return projectSpaceMember(member, defaultCaps), nil + return toSpaceMember(member, defaultCaps), nil } // hasOtherAuthorizedAdmin reports whether space's backing channel still has a SchemeAdmin member @@ -230,13 +230,13 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st // (guests are rejected above, so schemeGuest is always false here), still firing the WS // event. s.log.Warn("SetSpaceMemberCapabilities: post-commit member re-read failed; responding from the requested set", "space_id", space.Id, "user_id", targetUserID, "err", memErr) - result = projectSpaceMember(&mmmodel.ChannelMember{ + result = toSpaceMember(&mmmodel.ChannelMember{ UserId: targetUserID, ExplicitRoles: newRoles, SchemeAdmin: newSchemeAdmin, }, defaultCaps) } else { - result = projectSpaceMember(fresh, defaultCaps) + result = toSpaceMember(fresh, defaultCaps) } payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} diff --git a/server/store/scheme_store.go b/server/store/scheme_store.go index ef443ca..f6d1ea7 100644 --- a/server/store/scheme_store.go +++ b/server/store/scheme_store.go @@ -125,7 +125,7 @@ func (s *Store) GetRolePermissionsByName(roleName string) ([]string, error) { // scheme's id. The scheme is unreferenced by any channel until the caller repoints the space's // backing channel at it. func (s *Store) CreateSpaceCustomScheme(userPermissions, adminPermissions, guestPermissions []string) (_ string, err error) { - tx, cancel, err := s.beginTxBounded() + tx, cancel, err := s.beginBoundedTx() if err != nil { return "", errors.Wrap(err, "begin_transaction") } @@ -210,7 +210,7 @@ func (s *Store) DeleteSpaceCustomSchemeIfUnreferenced(schemeID, excludeChannelID return &ErrInvalidInput{Entity: "Scheme", Field: "id", Value: schemeID} } - tx, cancel, err := s.beginTxBounded() + tx, cancel, err := s.beginBoundedTx() if err != nil { return errors.Wrap(err, "begin_transaction") } diff --git a/server/store/space_store.go b/server/store/space_store.go index 3465846..2d4735f 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -138,7 +138,7 @@ func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpd return nil, &ErrInvalidInput{Entity: "Space", Field: "Patch", Value: validErr.Error(), Reason: validErr.Id} } - tx, cancel, err := s.beginTxBounded() + tx, cancel, err := s.beginBoundedTx() if err != nil { return nil, errors.Wrap(err, "begin_transaction") } @@ -369,7 +369,7 @@ const ( // transaction of its own. fn may perform store work of its own — reads and its own transactions // — so each in-flight caller holds two pooled connections for the critical section: this // lock's session connection plus any connection fn's own transaction acquires. Any transaction fn -// opens must therefore bound its connection acquisition (see beginTxBounded), or a saturated pool +// opens must therefore bound its connection acquisition (see beginBoundedTx), or a saturated pool // can leave every lock holder waiting on a connection no other holder will release. fn must also // stay short: the lock connection is held for fn's whole duration, and cross-process // serialization of a read-modify-write that spans non-database calls has no cheaper primitive. diff --git a/server/store/store.go b/server/store/store.go index d0beb1a..b6fcec0 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -168,13 +168,13 @@ func (s *Store) finalizeTransaction(tx *sqlx.Tx, perr *error) { } } -// beginTxBounded starts a transaction whose connection acquisition is bounded by +// beginBoundedTx starts a transaction whose connection acquisition is bounded by // defaultQueryTimeout, and must be used by any transaction that can run while its caller already // holds WithSpaceMembershipLock's dedicated connection. Such a caller needs a second pooled // connection while holding one, so an unbounded acquisition can wait forever on a saturated pool // while itself holding a connection that pool needs in order to drain. The returned cancel must be // deferred by the caller: it bounds acquisition, and releasing it ends the transaction's context. -func (s *Store) beginTxBounded() (*sqlx.Tx, context.CancelFunc, error) { +func (s *Store) beginBoundedTx() (*sqlx.Tx, context.CancelFunc, error) { ctx, cancel := context.WithTimeout(context.Background(), defaultQueryTimeout) tx, err := s.db.BeginTxx(ctx, nil) if err != nil { diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 1ab23b7..6021fad 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -58,7 +58,6 @@ "docs.sidebar.add.browse": "Browse spaces", "docs.sidebar.add.create": "Create a space", "docs.sidebar.add.menu": "Add or browse spaces", - "docs.sidebar.category.favorites": "Favorites", "docs.sidebar.category.spaces": "Spaces", "docs.sidebar.createSpace": "Create a space", "docs.sidebar.favorites.empty": "Drag favorite items here or click the star icon on any space", @@ -66,10 +65,8 @@ "docs.sidebar.nav.home": "Home", "docs.sidebar.search.placeholder": "Find docs", "docs.sidebar.space.copyLink": "Copy link", - "docs.sidebar.space.favorite": "Add to favorites", "docs.sidebar.space.leave": "Leave space", "docs.sidebar.space.menu": "Space options for {name}", - "docs.sidebar.space.unfavorite": "Remove from favorites", "docs.sidebar.team.createTeam": "Create a team", "docs.sidebar.team.invite": "Invite people", "docs.sidebar.team.invite.secondary": "Add or invite people to the team", From 9839c2e7d3fbd8a708ca0c71b6d917efcfa878b1 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 29 Jul 2026 14:07:56 +0200 Subject: [PATCH 18/28] more renaming --- server/api_handler_test.go | 12 +++--- server/app/space.go | 56 +++++++++++++------------ server/app/space_members.go | 26 ++++++------ server/model/page_content.go | 2 +- server/model/space_capabilities.go | 46 ++++++++++---------- server/model/space_capabilities_test.go | 22 +++++----- server/store/page_hierarchy.go | 2 +- 7 files changed, 84 insertions(+), 82 deletions(-) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index be37c93..c759faf 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -1850,8 +1850,8 @@ func TestHandler_SetSpaceMemberCapabilities_InvalidCapability(t *testing.T) { space := seedSpace(t, h.store, h.db, channelID) cases := []struct { - name string - caps []string + name string + capabilities []string }{ {"read_page is not grantable", []string{"read_page"}}, {"unknown token is rejected", []string{"not_a_real_capability"}}, @@ -1859,7 +1859,7 @@ func TestHandler_SetSpaceMemberCapabilities_InvalidCapability(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ - "granted_capabilities": tc.caps, + "granted_capabilities": tc.capabilities, }) require.Equal(t, http.StatusBadRequest, rec.Code) }) @@ -2143,8 +2143,8 @@ func TestHandler_SetSpaceDefaultCapabilities_InvalidCapability(t *testing.T) { space := seedSpace(t, h.store, h.db, channelID) cases := []struct { - name string - caps []string + name string + capabilities []string }{ {"read_page is implicit, not settable", []string{"read_page"}}, {"admin_space is member-grant-only", []string{"admin_space"}}, @@ -2153,7 +2153,7 @@ func TestHandler_SetSpaceDefaultCapabilities_InvalidCapability(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ - "default_capabilities": tc.caps, + "default_capabilities": tc.capabilities, }) require.Equal(t, http.StatusBadRequest, rec.Code) }) diff --git a/server/app/space.go b/server/app/space.go index b689b95..41db2b4 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -135,24 +135,26 @@ func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) bo return true } -// resolveSpaceScheme resolves the backing-channel scheme id for a space-default capability set: -// a set matching one of the three seeded presets repoints to the shared preset scheme; any other -// set creates a new space-private immutable custom scheme. Returns the scheme id and -// whether a custom scheme was created (so the caller can retire it on a later create-step -// failure). -func (s *Service) resolveSpaceScheme(caps []string) (schemeID string, createdCustom bool, err error) { +// resolveSpaceScheme picks the backing-channel scheme that gives a space's plain members the +// requested capabilities. A set matching one of the seeded presets resolves to that preset's +// scheme, which is shared by every space using the same preset. Any other set gets a scheme +// created here and used by this space alone. +// +// createdCustom tells those two cases apart, so a caller whose next create step fails knows +// whether there is a scheme of its own to retire. +func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, createdCustom bool, err error) { // Normalize before the permission set is persisted: the validators are dedup-tolerant, so // without this a request repeating one allowlisted token would write that repetition verbatim // into the generated role's Permissions column. - caps = model.NormalizeCapabilitySet(caps) - if presetName, ok := model.SchemeNameForDefaultCapabilities(caps); ok { + capabilities = model.NormalizeCapabilitySet(capabilities) + if presetName, ok := model.SchemeNameForDefaultCapabilities(capabilities); ok { id, getErr := s.store.GetSchemeIDByName(presetName) if getErr != nil { return "", false, getErr } return id, false, nil } - userPerms := append([]string{model.CapabilityReadPage}, caps...) + userPerms := append([]string{model.CapabilityReadPage}, capabilities...) adminPerms := mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions) guestPerms := []string{model.CapabilityReadPage} id, createErr := s.store.CreateSpaceCustomScheme(userPerms, adminPerms, guestPerms) @@ -262,15 +264,15 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili } space.ViewAccess = va - caps, _ := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + capabilities, _ := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) if defaultCapabilities != nil { - caps = *defaultCapabilities + capabilities = *defaultCapabilities } - if capErr := model.ValidateDefaultCapabilities(caps); capErr != nil { + if capErr := model.ValidateDefaultCapabilities(capabilities); capErr != nil { return nil, capErr } - schemeID, createdCustom, schemeErr := s.resolveSpaceScheme(caps) + schemeID, createdCustom, schemeErr := s.resolveSpaceScheme(capabilities) if schemeErr != nil { return nil, storeAppError("CreateSpace", schemeErr) } @@ -372,8 +374,8 @@ func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) // defaultCapabilitiesForRoles is spaceDefaultCapabilities for a caller that already holds the // backing channel's scheme roles. func (s *Service) defaultCapabilitiesForRoles(roles *store.SchemeRoles) ([]string, error) { - if caps, ok := model.DefaultCapabilitiesForSchemeName(roles.SchemeName); ok { - return caps, nil + if capabilities, ok := model.DefaultCapabilitiesForSchemeName(roles.SchemeName); ok { + return capabilities, nil } perms, err := s.store.GetRolePermissionsByName(roles.UserRoleName) if err != nil { @@ -401,28 +403,28 @@ func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*mode if resolution == ReadDenied { return nil, existenceHidingForbidden("BuildSpaceWithAccess") } - defaultCaps, err := s.spaceDefaultCapabilities(space) + defaultCapabilities, err := s.spaceDefaultCapabilities(space) if err != nil { return nil, storeAppError("BuildSpaceWithAccess", err) } - var caps []string + var capabilities []string switch resolution { case ReadViaSysadmin: - caps = model.AdminEffectiveCapabilities() + capabilities = model.AdminEffectiveCapabilities() case ReadViaMember: member, memErr := s.client.Channel.GetMember(space.ChannelId, userID) if memErr != nil { return nil, mmmodel.NewAppError("BuildSpaceWithAccess", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) } - caps = model.CapabilitiesFromMember(member.ExplicitRoles, member.SchemeAdmin, member.SchemeGuest, defaultCaps).Effective + capabilities = model.CapabilitiesFromMember(member.ExplicitRoles, member.SchemeAdmin, member.SchemeGuest, defaultCapabilities).Effective case ReadViaOpenFallthrough: - caps = []string{model.CapabilityReadPage} + capabilities = []string{model.CapabilityReadPage} default: return nil, existenceHidingForbidden("BuildSpaceWithAccess") } - wrapper := &model.SpaceWithAccess{Space: *space, DefaultCapabilities: defaultCaps, Capabilities: caps} + wrapper := &model.SpaceWithAccess{Space: *space, DefaultCapabilities: defaultCapabilities, Capabilities: capabilities} wrapper.EnsureCapabilities() return wrapper, nil } @@ -433,11 +435,11 @@ func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*mode // unreferenced. The repoint goes through pluginapi Channel.Update (not a store-direct write) so // core's member-cache invalidation runs and the new scheme takes effect on the next permission // check, rather than when the cache expires. -func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, actingUserID string) (*model.SpaceWithAccess, *mmmodel.AppError) { +func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities []string, actingUserID string) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } - if appErr := model.ValidateDefaultCapabilities(caps); appErr != nil { + if appErr := model.ValidateDefaultCapabilities(capabilities); appErr != nil { return nil, appErr } if appErr := s.requireClient("SetSpaceDefaultCapabilities", "space_id", space.Id); appErr != nil { @@ -463,13 +465,13 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, // scheme — so without this an unchanged custom set would create, repoint, and retire on // every save, and the id comparison below could never catch it. if currentRolesErr == nil { - if liveCaps, capsErr := s.defaultCapabilitiesForRoles(currentRoles); capsErr == nil && - slices.Equal(liveCaps, model.NormalizeCapabilitySet(caps)) { + if liveCapabilities, capsErr := s.defaultCapabilitiesForRoles(currentRoles); capsErr == nil && + slices.Equal(liveCapabilities, model.NormalizeCapabilitySet(capabilities)) { return nil } } - targetSchemeID, createdCustom, schemeErr := s.resolveSpaceScheme(caps) + targetSchemeID, createdCustom, schemeErr := s.resolveSpaceScheme(capabilities) if schemeErr != nil { return storeAppError("SetSpaceDefaultCapabilities", schemeErr) } @@ -513,7 +515,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, caps []string, if buildErr != nil { return nil, buildErr } - wrapper.DefaultCapabilities = model.NormalizeCapabilitySet(caps) + wrapper.DefaultCapabilities = model.NormalizeCapabilitySet(capabilities) return wrapper, nil } s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": fresh.Id}, fresh.ChannelId) diff --git a/server/app/space_members.go b/server/app/space_members.go index 7b355ba..b4a8d0a 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -28,7 +28,7 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod if appErr := s.requireClient("GetSpaceMembers", "space_id", space.Id); appErr != nil { return nil, false, appErr } - defaultCaps, err := s.spaceDefaultCapabilities(space) + defaultCapabilities, err := s.spaceDefaultCapabilities(space) if err != nil { return nil, false, storeAppError("GetSpaceMembers", err) } @@ -40,7 +40,7 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod } members := make([]*model.SpaceMember, 0, len(channelMembers)) for _, cm := range channelMembers { - members = append(members, toSpaceMember(cm, defaultCaps)) + members = append(members, toSpaceMember(cm, defaultCapabilities)) } hasMore := false if len(channelMembers) == perPage { @@ -57,8 +57,8 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod } // toSpaceMember builds the wire representation of a channel member's capability state. -func toSpaceMember(cm *mmmodel.ChannelMember, defaultCaps []string) *model.SpaceMember { - mc := model.CapabilitiesFromMember(cm.ExplicitRoles, cm.SchemeAdmin, cm.SchemeGuest, defaultCaps) +func toSpaceMember(cm *mmmodel.ChannelMember, defaultCapabilities []string) *model.SpaceMember { + mc := model.CapabilitiesFromMember(cm.ExplicitRoles, cm.SchemeAdmin, cm.SchemeGuest, defaultCapabilities) member := &model.SpaceMember{ UserId: cm.UserId, Capabilities: mc.Effective, @@ -97,7 +97,7 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) } } - defaultCaps, err := s.spaceDefaultCapabilities(space) + defaultCapabilities, err := s.spaceDefaultCapabilities(space) if err != nil { return nil, storeAppError("AddSpaceMember", err) } @@ -110,7 +110,7 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": member.UserId}, space.ChannelId) - return toSpaceMember(member, defaultCaps), nil + return toSpaceMember(member, defaultCapabilities), nil } // hasOtherAuthorizedAdmin reports whether space's backing channel still has a SchemeAdmin member @@ -127,21 +127,21 @@ func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID stri // additionally enforces the self/admin escalation guard and the last-admin invariant. Guest // members are rejected: they stay read-only via the scheme's guest role. actingUserID is the // caller, used only to decide the self-escalation guard. -func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID string, caps []string, actingUserID string) (*model.SpaceMember, *mmmodel.AppError) { +func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID string, capabilities []string, actingUserID string) (*model.SpaceMember, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } if !mmmodel.IsValidId(targetUserID) { return nil, mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } - if appErr := model.ValidateGrantedCapabilities(caps); appErr != nil { + if appErr := model.ValidateGrantedCapabilities(capabilities); appErr != nil { return nil, appErr } if appErr := s.requireClient("SetSpaceMemberCapabilities", "space_id", space.Id, "user_id", targetUserID); appErr != nil { return nil, appErr } - requestedAdmin := slices.Contains(caps, model.CapabilityAdminSpace) + requestedAdmin := slices.Contains(capabilities, model.CapabilityAdminSpace) selfTargeted := targetUserID == actingUserID // The scheme-role read, the target's current admin status, the escalation-guard decision, and @@ -162,7 +162,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st appErr = storeAppError("SetSpaceMemberCapabilities", rolesErr) return appErr } - newRoles, newSchemeAdmin = model.RolesForCapabilities(caps, schemeRoles.UserRoleName) + newRoles, newSchemeAdmin = model.RolesForCapabilities(capabilities, schemeRoles.UserRoleName) target, memErr := s.client.Channel.GetMember(space.ChannelId, targetUserID) if memErr != nil { @@ -218,7 +218,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st // The scheme roles read under the lock still describe this channel: only // SetSpaceDefaultCapabilities repoints a channel's scheme, and it serializes behind the same // space-keyed lock, so the default capability set is projected from them rather than re-read. - defaultCaps, defErr := s.defaultCapabilitiesForRoles(schemeRoles) + defaultCapabilities, defErr := s.defaultCapabilitiesForRoles(schemeRoles) if defErr != nil { return nil, storeAppError("SetSpaceMemberCapabilities", defErr) } @@ -234,9 +234,9 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st UserId: targetUserID, ExplicitRoles: newRoles, SchemeAdmin: newSchemeAdmin, - }, defaultCaps) + }, defaultCapabilities) } else { - result = toSpaceMember(fresh, defaultCaps) + result = toSpaceMember(fresh, defaultCapabilities) } payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} diff --git a/server/model/page_content.go b/server/model/page_content.go index e4f45a9..a7d8728 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -126,7 +126,7 @@ func writeSearchTextPart(b *strings.Builder, part string) { b.WriteString(part) } -// maxTipTapDepth bounds recursion over client-supplied content. encoding/json already caps nesting, +// maxTipTapDepth bounds recursion over client-supplied content. encoding/json already capabilities nesting, // but this rejects a pathologically deep document before the recursive walk and keeps stored content // within a sane depth. const maxTipTapDepth = 100 diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index c5a87e4..2ed524e 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -94,11 +94,11 @@ var presetCapabilitySets = map[string][]string{ mmmodel.SchemeNameSpaceReadOnly: stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions), } -// validateCapabilities validates caps against allowed, rejecting read_page as the non-grantable +// validateCapabilities validates capabilities against allowed, rejecting read_page as the non-grantable // baseline and delete_page as admin-only, plus admin_space when rejectAdmin is set. An unknown // token is rejected. Dedup-tolerant. where attributes the rejection to the calling validator. -func validateCapabilities(where string, caps []string, allowed map[string]bool, rejectAdmin bool) *mmmodel.AppError { - for _, c := range caps { +func validateCapabilities(where string, capabilities []string, allowed map[string]bool, rejectAdmin bool) *mmmodel.AppError { + for _, c := range capabilities { if c == CapabilityReadPage { return mmmodel.NewAppError(where, "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) } @@ -117,15 +117,15 @@ func validateCapabilities(where string, caps []string, allowed map[string]bool, // ValidateGrantedCapabilities validates a per-member granted-capability request: each token must // be one of the grantable member capabilities. -func ValidateGrantedCapabilities(caps []string) *mmmodel.AppError { - return validateCapabilities("ValidateGrantedCapabilities", caps, grantableMemberCapabilities, false) +func ValidateGrantedCapabilities(capabilities []string) *mmmodel.AppError { + return validateCapabilities("ValidateGrantedCapabilities", capabilities, grantableMemberCapabilities, false) } // ValidateDefaultCapabilities validates a space-default capability set: same rule as // ValidateGrantedCapabilities, plus admin_space is also rejected — a space default is never // admin-granting. -func ValidateDefaultCapabilities(caps []string) *mmmodel.AppError { - return validateCapabilities("ValidateDefaultCapabilities", caps, grantableDefaultCapabilities, true) +func ValidateDefaultCapabilities(capabilities []string) *mmmodel.AppError { + return validateCapabilities("ValidateDefaultCapabilities", capabilities, grantableDefaultCapabilities, true) } // RolesForCapabilities maps the requested non-admin capabilities to their atomic role names for @@ -133,9 +133,9 @@ func ValidateDefaultCapabilities(caps []string) *mmmodel.AppError { // schemeUserRole token is always emitted first — core rejects a member update that leaves the base // scheme role unset. Pure: schemeUserRole (the scheme's generated user-role name) comes from the // caller, resolved via a store lookup elsewhere. -func RolesForCapabilities(caps []string, schemeUserRole string) (explicitRoles string, schemeAdmin bool) { +func RolesForCapabilities(capabilities []string, schemeUserRole string) (explicitRoles string, schemeAdmin bool) { roles := []string{schemeUserRole} - for _, c := range NormalizeCapabilitySet(caps) { + for _, c := range NormalizeCapabilitySet(capabilities) { if c == CapabilityAdminSpace { schemeAdmin = true continue @@ -160,14 +160,14 @@ type MemberCapabilities struct { // CapabilitiesFromMember reverse-projects a member's raw role state onto the capability vocabulary. // explicitRoles is the raw space-delimited ChannelMember.ExplicitRoles string; any token that is // not an atomic capability role is ignored (harmless if the base scheme token is passed too). -// defaultCaps is the space's default capability set (wire form, read_page-free). +// defaultCapabilities is the space's default capability set (wire form, read_page-free). // // A SchemeGuest member's effective set is read_page union Granted only — never the space default, // since a guest resolves through the read-only DefaultChannelGuestRole, not the scheme's user-role // default. A SchemeAdmin member's effective set additionally includes the full canonical admin // capability set, since SchemeAdmin resolves through DefaultChannelAdminRole regardless of what is // (or isn't) recorded in ExplicitRoles/the space default. Pure: the model never touches the store. -func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, defaultCaps []string) MemberCapabilities { +func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, defaultCapabilities []string) MemberCapabilities { granted := make(map[string]bool) for token := range strings.FieldsSeq(explicitRoles) { if c, ok := atomicRoleCapability[token]; ok { @@ -186,7 +186,7 @@ func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, effective[c] = true } } - for _, c := range defaultCaps { + for _, c := range defaultCapabilities { effective[c] = true } } @@ -225,18 +225,18 @@ func DefaultCapabilitiesFromPermissions(permissions []string) []string { // DefaultCapabilitiesForSchemeName returns the wire-form default capability set for one of the // three seeded preset scheme names, or false if name is not a preset. func DefaultCapabilitiesForSchemeName(name string) ([]string, bool) { - caps, ok := presetCapabilitySets[name] + capabilities, ok := presetCapabilitySets[name] if !ok { return nil, false } - return NormalizeCapabilitySet(caps), true + return NormalizeCapabilitySet(capabilities), true } -// SchemeNameForDefaultCapabilities returns the seeded preset scheme name matching caps, or false -// if caps does not match any preset. Recognition is set equality — order-insensitive, deduplicated +// SchemeNameForDefaultCapabilities returns the seeded preset scheme name matching capabilities, or false +// if capabilities does not match any preset. Recognition is set equality — order-insensitive, deduplicated // — never a raw array comparison. -func SchemeNameForDefaultCapabilities(caps []string) (string, bool) { - normalized := NormalizeCapabilitySet(caps) +func SchemeNameForDefaultCapabilities(capabilities []string) (string, bool) { + normalized := NormalizeCapabilitySet(capabilities) for name, preset := range presetCapabilitySets { if slices.Equal(NormalizeCapabilitySet(preset), normalized) { return name, true @@ -245,11 +245,11 @@ func SchemeNameForDefaultCapabilities(caps []string) (string, bool) { return "", false } -// NormalizeCapabilitySet dedupes and sorts caps into a deterministic, non-nil capability slice. -func NormalizeCapabilitySet(caps []string) []string { - seen := make(map[string]bool, len(caps)) - out := make([]string, 0, len(caps)) - for _, c := range caps { +// NormalizeCapabilitySet dedupes and sorts capabilities into a deterministic, non-nil capability slice. +func NormalizeCapabilitySet(capabilities []string) []string { + seen := make(map[string]bool, len(capabilities)) + out := make([]string, 0, len(capabilities)) + for _, c := range capabilities { if c == "" || seen[c] { continue } diff --git a/server/model/space_capabilities_test.go b/server/model/space_capabilities_test.go index bdd0784..41ac654 100644 --- a/server/model/space_capabilities_test.go +++ b/server/model/space_capabilities_test.go @@ -31,15 +31,15 @@ func TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip(t *testing.T) { const schemeUserRole = "generated_scheme_user_role" for mask := range 1 << len(grantableCapabilities) { - var caps []string + var capabilities []string for i, c := range grantableCapabilities { if mask&(1< 0 && slices.Contains(caps, model.CapabilityAdminSpace), mc.IsAdmin) + require.Equal(t, want, mc.Granted, "capabilities=%v", capabilities) + require.Equal(t, len(capabilities) > 0 && slices.Contains(capabilities, model.CapabilityAdminSpace), mc.IsAdmin) } } @@ -68,9 +68,9 @@ func TestCapabilitiesFromMember_Guest(t *testing.T) { t.Run("surviving grants project without the default", func(t *testing.T) { explicitRoles := mmmodel.SpacePageCreatorRoleId + " " + mmmodel.SpacePageEditorRoleId // A contribute default that must NOT leak into a guest's effective set. - defaultCaps := []string{model.CapabilityCommentPage, model.CapabilityCreatePage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage} + defaultCapabilities := []string{model.CapabilityCommentPage, model.CapabilityCreatePage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage} - mc := model.CapabilitiesFromMember(explicitRoles, false, true, defaultCaps) + mc := model.CapabilitiesFromMember(explicitRoles, false, true, defaultCapabilities) require.Equal(t, model.NormalizeCapabilitySet([]string{model.CapabilityCreatePage, model.CapabilityEditPage}), mc.Granted) require.Equal(t, model.NormalizeCapabilitySet([]string{model.CapabilityReadPage, model.CapabilityCreatePage, model.CapabilityEditPage}), mc.Effective) @@ -91,16 +91,16 @@ func TestPresetRoundTrip(t *testing.T) { for _, name := range presetNames { t.Run(name, func(t *testing.T) { - caps, ok := model.DefaultCapabilitiesForSchemeName(name) + capabilities, ok := model.DefaultCapabilitiesForSchemeName(name) require.True(t, ok) - recognizedName, ok := model.SchemeNameForDefaultCapabilities(caps) + recognizedName, ok := model.SchemeNameForDefaultCapabilities(capabilities) require.True(t, ok) require.Equal(t, name, recognizedName) roundTripped, ok := model.DefaultCapabilitiesForSchemeName(recognizedName) require.True(t, ok) - require.Equal(t, caps, roundTripped) + require.Equal(t, capabilities, roundTripped) }) } diff --git a/server/store/page_hierarchy.go b/server/store/page_hierarchy.go index bfbe635..aeb0290 100644 --- a/server/store/page_hierarchy.go +++ b/server/store/page_hierarchy.go @@ -19,7 +19,7 @@ const MaxPageDescendantsLimit = 5000 // Matches MaxPageDescendantsLimit so a full-fan-out subtree still fits the descendant cap. const MaxPageSiblingsLimit = MaxPageDescendantsLimit -// MaxRowsPerQuery caps unpaginated reads to prevent unbounded result sets. +// MaxRowsPerQuery capabilities unpaginated reads to prevent unbounded result sets. // Paginated listings reject a non-positive limit outright rather than falling back to this cap. const MaxRowsPerQuery = 5000 From 34ad1e9f86d2f92823903c16a878bf178bf6e126 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 3 Aug 2026 17:54:21 +0200 Subject: [PATCH 19/28] using pluginapi instead of core table handling from plugin --- assets/i18n/en.json | 8 + go.mod | 4 + go.sum | 2 - server/api_handler_test.go | 208 +++++++++------- server/app/page_move_to_space_test.go | 4 +- server/app/permissions.go | 2 +- server/app/scheme.go | 229 ++++++++++++++++++ server/app/space.go | 187 +++++++++------ server/app/space_members.go | 11 +- server/app/space_test.go | 319 +++++++++++++++++++++++-- server/e2e/scenarios_test.go | 58 +++++ server/internal/testutil/fixtures.go | 130 ---------- server/internal/testutil/schemestub.go | 247 +++++++++++++++++++ server/store/scheme_store.go | 272 --------------------- server/store/scheme_store_test.go | 147 ------------ 15 files changed, 1085 insertions(+), 743 deletions(-) create mode 100644 server/app/scheme.go create mode 100644 server/internal/testutil/schemestub.go delete mode 100644 server/store/scheme_store.go delete mode 100644 server/store/scheme_store_test.go diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 603d343..08a4a8d 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -447,6 +447,10 @@ "id": "app.space.create.not_team_member.app_error", "translation": "You must be a member of the team to create a space in it." }, + { + "id": "app.space.create.scheme_configure_failed.app_error", + "translation": "Failed to configure the space's permission scheme." + }, { "id": "app.space.create.scheme_lookup_failed.app_error", "translation": "Failed to look up the space's permission scheme." @@ -463,6 +467,10 @@ "id": "app.space.default_capabilities.repoint_failed.app_error", "translation": "Failed to apply the new default capabilities." }, + { + "id": "app.space.default_capabilities.scheme_configure_failed.app_error", + "translation": "Failed to configure the space's new permission scheme." + }, { "id": "app.space.delete.invalid_id.app_error", "translation": "Invalid space ID." diff --git a/go.mod b/go.mod index ecf044f..f6e3c02 100644 --- a/go.mod +++ b/go.mod @@ -130,3 +130,7 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.50.1 // indirect ) + +// Local dev pin against the paired core branch while its scheme/role plugin-API additions are +// unmerged. Replace with a pseudo-version bump once those land upstream. +replace github.com/mattermost/mattermost/server/public => /Users/catalintomai/mattermost/MM-69269-core/server/public diff --git a/go.sum b/go.sum index d105a18..1090279 100644 --- a/go.sum +++ b/go.sum @@ -196,8 +196,6 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260727152953-09b1991e5d5d h1:Ps8dcPd1SHI8SK261SE7VEytbRHcF0ndirbDQeyMfjw= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260727152953-09b1991e5d5d/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= diff --git a/server/api_handler_test.go b/server/api_handler_test.go index c759faf..1a8023d 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -17,6 +17,7 @@ import ( "io" "net/http" "net/http/httptest" + "slices" "strings" "testing" @@ -64,11 +65,19 @@ func openTestPlugin(t *testing.T, mockAPI *plugintest.API) *apiTestHarness { mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")).Return(mmmodel.ChannelMembers{}, nil).Maybe() mockAPI.On("GetChannelStats", mock.Anything).Return(&mmmodel.ChannelStats{}, nil).Maybe() - mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil).Maybe() // writeAppError logs 500-class failures (message plus four key/value pairs) regardless of - // which mock a test supplies, so stub it universally. + // which mock a test supplies, so stub it universally. The two-pair shape covers the + // custom-scheme retire failures. + mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() testutil.StubDefaultSpacePermissions(mockAPI) + testutil.StubPresetSchemes(mockAPI) + // Resolves any channel to the contribute preset, and serves the channel read/write that + // UpdateSpace's best-effort metadata sync (syncSpaceChannelMetadata) performs. Registered last + // because mock.Mock matches in registration order: a test seeding a channel at a specific + // (non-contribute) scheme, or asserting a repoint, registers its own stub before calling + // openTestPlugin (testutil.MustSeedChannelScheme / stubSpaceSchemeRepoint) so it wins. + testutil.StubDefaultChannelScheme(mockAPI) // CreateSpace assigns the creator SchemeAdmin via the scheme's resolved role-name string; no // test asserts the exact roles argument, so a wildcard catch-all covers every create. mockAPI.On("UpdateChannelMemberRoles", mock.Anything, mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() @@ -142,9 +151,12 @@ func seedSpace(t *testing.T, s *store.Store, db *sql.DB, channelID string) *mode return seedSpaceInTeam(t, s, db, channelID, mmmodel.NewId()) } +// seedSpaceInTeam creates a space via the store directly. Its channel resolves to the contribute +// preset through the generic catch-all (testutil.StubDefaultChannelScheme, wired in +// openTestPlugin), so no per-channel scheme stub is registered here. func seedSpaceInTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() - return testutil.MustCreateSpaceWithScheme(t, s, db, channelID, teamID) + return testutil.MustCreateSpace(t, s, channelID, teamID) } func seedPage(t *testing.T, s *store.Store, spaceID, channelID, parentID string) *model.Page { @@ -186,7 +198,6 @@ func TestHandler_CreateSpace(t *testing.T) { mockAPI.On("UpdateChannelMemberRoles", backingChannelID, mock.Anything, mock.Anything). Return(&mmmodel.ChannelMember{}, nil) h := openTestPlugin(t, mockAPI) - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) teamID := mmmodel.NewId() rec := h.do(t, http.MethodPost, "/api/v1/teams/"+teamID+"/spaces", mmmodel.NewId(), map[string]any{ @@ -217,7 +228,6 @@ func TestHandler_CreateSpace_IgnoresServerOwnedFields(t *testing.T) { mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) h := openTestPlugin(t, mockAPI) - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) forgedID := mmmodel.NewId() teamID := mmmodel.NewId() @@ -1788,7 +1798,6 @@ func TestHandler_RemoveSpaceMember_NonSelfMissingTargetIsNotFound(t *testing.T) space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: mmmodel.NewId(), CreatorId: mmmodel.NewId(), Title: "Private Space", ViewAccess: model.ViewAccessPrivate}) require.NoError(t, err) - testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, adminID, nil) require.Equal(t, http.StatusNotFound, rec.Code) @@ -1884,11 +1893,14 @@ func TestHandler_SetSpaceMemberCapabilities_Grant(t *testing.T) { // the projection is pinned by the specific role token the requested capability set maps to. mockAPI.On("GetChannelMember", channelID, targetUserID). Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, ExplicitRoles: mmmodel.SpacePageCreatorRoleId}, nil) + // Registered before openTestPlugin: the read-only preset must win over + // StubDefaultChannelScheme's contribute-preset catch-all, and mock matching is by + // registration order. + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceReadOnly) h := openTestPlugin(t, mockAPI) space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "RO Space", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) - testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceReadOnly) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{"create_page"}, @@ -2041,37 +2053,75 @@ func TestHandler_SetSpaceMemberCapabilities_PublishesEvent(t *testing.T) { payload, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) } -// stubSpaceSchemeRepoint wires GetChannelOfType/UpdateChannel so a SetSpaceDefaultCapabilities -// repoint against channelID is observable: the shared *model.Channel returned by GetChannelOfType -// is mutated in place by UpdateChannel's SchemeId argument, and that same value is written into -// the isolated test DB's Channels stand-in row, keeping subsequent store-side scheme resolution -// (GetSchemeRolesForChannel, spaceDefaultCapabilities — which read the DB row directly, not the -// mocked channel) consistent with the mocked channel across repeated calls in the same test. +// stubSpaceSchemeRepoint makes a SetSpaceDefaultCapabilities repoint against channelID observable: +// the returned shared *model.Channel is the one GetChannelOfType hands out, so the repoint's +// in-place SchemeId write is visible to the caller and to every subsequent scheme-resolving read. +// The channel starts pointed at the contribute preset scheme, mirroring seedSpace's default. // -// Must be registered before openTestPlugin (GetChannelOfType's default catch-all would otherwise -// shadow it — the same registration-order rule as grantSpaceManage/grantSpaceAdmin). hp is -// assigned by the caller once openTestPlugin returns, and the returned *model.Channel lets the -// caller seed a specific starting scheme id once the harness (and its seeded preset scheme ids) -// exists. -func stubSpaceSchemeRepoint(t *testing.T, mockAPI *plugintest.API, hp **apiTestHarness, channelID string) *mmmodel.Channel { +// Must be registered before openTestPlugin, whose testutil.StubDefaultChannelScheme catch-all would +// otherwise shadow it for this channelID — the same registration-order rule as +// grantSpaceManage/grantSpaceAdmin. +func stubSpaceSchemeRepoint(t *testing.T, mockAPI *plugintest.API, channelID string) *mmmodel.Channel { t.Helper() - channel := &mmmodel.Channel{Id: channelID, SchemeId: mmmodel.NewPointer(mmmodel.NewId())} - mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace).Return(channel, nil) - // pluginapi's Channel.Update does `*channel = *updatedChannel` after this call, so the mock - // must return the same shared object (already mutated to the new SchemeId by the caller before - // Update runs) — returning a fresh, empty Channel would wipe the shared object's SchemeId back - // to nil on the very next GetChannelOfType read. Not every code path calls Update (a no-op - // resubmit never does), so this stub is Maybe(). - mockAPI.On("UpdateChannel", mock.AnythingOfType("*model.Channel")). - Run(func(args mock.Arguments) { - updated, ok := args.Get(0).(*mmmodel.Channel) - require.True(t, ok) - require.NotNil(t, updated.SchemeId) - _, dbErr := (*hp).db.Exec(`UPDATE Channels SET SchemeId = $1 WHERE Id = $2`, *updated.SchemeId, channelID) - require.NoError(t, dbErr) - }). - Return(channel, nil).Maybe() - return channel + return testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) +} + +// spaceCustomSchemeNamePrefix mirrors the plugin-internal prefix app.createSpaceCustomScheme labels +// its schemes with. It is restated here rather than imported because it is unexported, and it is +// only a label: core proves a scheme's space scope by the backing channel pointing at it, never by +// the name. +const spaceCustomSchemeNamePrefix = "docs_space_custom_" + +// stubSpaceCustomSchemeCreate wires the mock calls the custom-scheme path needs for a non-preset +// default-capability set: CreateScheme returning a scheme whose Name carries the custom prefix, +// plus GetRoleByName and PatchRole for each of its three generated roles. The new scheme's role +// names are registered with testutil so a channel repointed at it resolves them through +// GetSchemeRolesForChannel, the way core does. +// +// Returns the scheme id CreateScheme resolves to, so the caller can assert against it (e.g. the +// channel's post-repoint SchemeId, or a later DeleteScheme call retiring it). +func stubSpaceCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { + t.Helper() + customSchemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "custom_scheme_user_role", "custom_scheme_admin_role", "custom_scheme_guest_role" + customScheme := &mmmodel.Scheme{ + Id: customSchemeID, + Name: spaceCustomSchemeNamePrefix + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + } + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(customScheme, nil) + testutil.RegisterSchemeRoles(customSchemeID, guestRole, userRole, adminRole) + // The generated roles start empty, the way core's CreateScheme leaves them before the plugin + // patches in the exact sets. StubRole hands back one shared *Role per name so StubPatchRole's + // mutation is visible to a later getRolePermissionsByName read of the same role. + testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + testutil.StubPatchRole(mockAPI) + return customSchemeID +} + +// assertCustomSchemeRolePermissions pins which permission set each generated role received, so a +// regression that sent the space-admin set to the guest or user role fails instead of passing on a +// single catch-all PatchRole expectation. capabilities is the requested default set. +func assertCustomSchemeRolePermissions(t *testing.T, mockAPI *plugintest.API, capabilities []string) { + t.Helper() + expected := map[string][]string{ + "custom_scheme_user_role": append([]string{model.CapabilityReadPage}, capabilities...), + "custom_scheme_admin_role": mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions), + "custom_scheme_guest_role": {model.CapabilityReadPage}, + } + for roleName, perms := range expected { + mockAPI.AssertCalled(t, "PatchRole", + mock.MatchedBy(func(r *mmmodel.Role) bool { return r.Name == roleName }), + mock.MatchedBy(func(p *mmmodel.RolePatch) bool { + return p != nil && p.Permissions != nil && slices.Equal( + model.NormalizeCapabilitySet(*p.Permissions), model.NormalizeCapabilitySet(perms)) + })) + } } // TestHandler_SetSpaceDefaultCapabilities_Forbidden verifies a manage-only caller (team @@ -2099,11 +2149,10 @@ func TestHandler_SetSpaceDefaultCapabilities_Allowed(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - var h *apiTestHarness mockAPI := newEnabledMockAPI() grantSpaceAdmin(mockAPI, channelID, userID) - stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) - h = openTestPlugin(t, mockAPI) + stubSpaceSchemeRepoint(t, mockAPI, channelID) + h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ @@ -2116,11 +2165,10 @@ func TestHandler_SetSpaceDefaultCapabilities_Allowed(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - var h *apiTestHarness mockAPI := newEnabledMockAPI() grantSysadmin(mockAPI, userID) - stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) - h = openTestPlugin(t, mockAPI) + stubSpaceSchemeRepoint(t, mockAPI, channelID) + h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ @@ -2167,11 +2215,11 @@ func TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - var h *apiTestHarness mockAPI := newEnabledMockAPI() grantSpaceAdmin(mockAPI, channelID, userID) - stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) - h = openTestPlugin(t, mockAPI) + channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) + customSchemeID := stubSpaceCustomSchemeCreate(t, mockAPI) + h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ @@ -2183,12 +2231,15 @@ func TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) require.Equal(t, []string{"create_page"}, updated.DefaultCapabilities) - var schemeName string - require.NoError(t, h.db.QueryRow( - `SELECT s.Name FROM Channels c JOIN Schemes s ON s.Id = c.SchemeId WHERE c.Id = $1`, channelID, - ).Scan(&schemeName)) - require.NotEmpty(t, schemeName) - require.False(t, mmmodel.IsSpaceSchemeName(schemeName), "expected a space-custom scheme, not a seeded preset, got %q", schemeName) + mockAPI.AssertCalled(t, "CreateScheme", mock.MatchedBy(func(s *mmmodel.Scheme) bool { + return strings.HasPrefix(s.Name, spaceCustomSchemeNamePrefix) + })) + require.NotNil(t, channel.SchemeId) + require.Equal(t, customSchemeID, *channel.SchemeId, "the channel must be repointed at the newly created custom scheme") + assertCustomSchemeRolePermissions(t, mockAPI, []string{"create_page"}) + // Exactly the three generated roles (user/admin/guest) are patched — no role skipped, none + // double-patched. assertCustomSchemeRolePermissions pins the per-role content; this pins the count. + mockAPI.AssertNumberOfCalls(t, "PatchRole", 3) } // TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomScheme verifies that @@ -2199,38 +2250,35 @@ func TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomSche channelID := mmmodel.NewId() userID := mmmodel.NewId() - var h *apiTestHarness mockAPI := newEnabledMockAPI() grantSpaceAdmin(mockAPI, channelID, userID) - stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) - h = openTestPlugin(t, mockAPI) + channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) + customSchemeID := stubSpaceCustomSchemeCreate(t, mockAPI) + mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, nil) + h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusOK, rec.Code) - - var customSchemeID string - require.NoError(t, h.db.QueryRow(`SELECT SchemeId FROM Channels WHERE Id = $1`, channelID).Scan(&customSchemeID)) + require.NotNil(t, channel.SchemeId) + require.Equal(t, customSchemeID, *channel.SchemeId) rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"comment_page", "create_page", "edit_page", "delete_own_page"}, }) require.Equal(t, http.StatusOK, rec.Code) - var contributeID string - require.NoError(t, h.db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, mmmodel.SchemeNameSpaceContribute).Scan(&contributeID)) - - var liveSchemeID string - require.NoError(t, h.db.QueryRow(`SELECT SchemeId FROM Channels WHERE Id = $1`, channelID).Scan(&liveSchemeID)) - require.Equal(t, contributeID, liveSchemeID) - - var schemeCount, roleCount int - require.NoError(t, h.db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, customSchemeID).Scan(&schemeCount)) - require.Zero(t, schemeCount, "the retired custom scheme must be gone") - require.NoError(t, h.db.QueryRow(`SELECT COUNT(*) FROM Roles WHERE SchemeId = $1`, customSchemeID).Scan(&roleCount)) - require.Zero(t, roleCount, "the retired custom scheme's roles must be gone") + require.NotNil(t, channel.SchemeId) + require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, + "the channel must be repointed at the shared contribute preset scheme") + mockAPI.AssertCalled(t, "DeleteScheme", customSchemeID) + // The custom scheme is minted once (first PATCH) and retired once (switch back). A regression + // that re-minted a scheme on the preset switch, or retired more than the one superseded scheme, + // would change these counts while still passing the AssertCalled checks above. + mockAPI.AssertNumberOfCalls(t, "CreateScheme", 1) + mockAPI.AssertNumberOfCalls(t, "DeleteScheme", 1) } // TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp verifies that resubmitting the @@ -2240,30 +2288,24 @@ func TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp(t *testing channelID := mmmodel.NewId() userID := mmmodel.NewId() - var h *apiTestHarness mockAPI := newEnabledMockAPI() grantSpaceAdmin(mockAPI, channelID, userID) - channel := stubSpaceSchemeRepoint(t, mockAPI, &h, channelID) - h = openTestPlugin(t, mockAPI) + // stubSpaceSchemeRepoint's channel already starts pointed at the contribute preset scheme, + // the same default seedSpace/MustCreateSpaceWithScheme use, so the requested set — which + // normalizes to that same preset — already matches the live scheme with no further setup. + channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) + contributeID := testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute) + h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - // seedSpace already points the channel at the real contribute preset scheme (in the test DB); - // point the mocked channel at that same real id so the requested set — which normalizes to the - // same preset — resolves to a target matching the current scheme. - var contributeID string - require.NoError(t, h.db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, mmmodel.SchemeNameSpaceContribute).Scan(&contributeID)) - channel.SchemeId = mmmodel.NewPointer(contributeID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"edit_page", "comment_page", "comment_page", "create_page", "delete_own_page"}, }) require.Equal(t, http.StatusOK, rec.Code) mockAPI.AssertNotCalled(t, "UpdateChannel", mock.Anything) - - var afterSchemeID string - require.NoError(t, h.db.QueryRow(`SELECT SchemeId FROM Channels WHERE Id = $1`, channelID).Scan(&afterSchemeID)) - require.Equal(t, contributeID, afterSchemeID, "resubmitting the current set must not repoint the channel") + require.NotNil(t, channel.SchemeId) + require.Equal(t, contributeID, *channel.SchemeId, "resubmitting the current set must not repoint the channel") } // TestHandler_UpdateSpace_ViewAccessRequiresAdmin verifies that a manage-only caller (no channel diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index fa7e5e4..1d6767f 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -19,9 +19,11 @@ import ( ) // seedSpaceForTeam creates a space with a caller-chosen team id (mustCreateSpace randomizes it). +// No test in this package resolves a scheme for a store-direct-created space, so no per-channel +// scheme stub is registered here. func seedSpaceForTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() - return testutil.MustCreateSpaceWithScheme(t, s, db, channelID, teamID) + return testutil.MustCreateSpace(t, s, channelID, teamID) } func pageIDs(pages []*model.PageSummary) map[string]bool { diff --git a/server/app/permissions.go b/server/app/permissions.go index 315fe37..3a7e280 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -273,7 +273,7 @@ func (s *Service) RequireSpacePublish(where string, space *model.Space, userID s // generated user role) grants perm to a plain member — the auto-join admission test. Channel // without a scheme (ErrNotFound) reports false, not an error. func (s *Service) DefaultRolesGrantPermission(space *model.Space, perm *mmmodel.Permission) (bool, error) { - roles, err := s.store.GetSchemeRolesForChannel(space.ChannelId) + roles, err := s.getSchemeRolesForChannel(space.ChannelId) if err != nil { if store.IsErrNotFound(err) { return false, nil diff --git a/server/app/scheme.go b/server/app/scheme.go new file mode 100644 index 0000000..2e764c1 --- /dev/null +++ b/server/app/scheme.go @@ -0,0 +1,229 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// spaceCustomSchemeDisplayName is the DisplayName of every space-private custom scheme: one +// immutable scheme per non-preset default-capability set. +const spaceCustomSchemeDisplayName = "Space Custom Scheme" + +// spaceCustomSchemeNamePrefix labels the Name of every space-private custom scheme this plugin +// creates. It is an operator-facing label only: core accepts a scheme name as proof of space scope +// solely for the three reserved preset names, and a per-space custom scheme proves its scope by +// having a space backing channel point at it instead. +const spaceCustomSchemeNamePrefix = "docs_space_custom_" + +// schemeRoles is the generated channel-scheme role names governing one backing channel's scheme. +// Space capability grants reference these generated names, not the literal +// channel_user/channel_admin roles: on a scheme-backed channel, core rejects the literal. +type schemeRoles struct { + UserRoleName string + AdminRoleName string + GuestRoleName string +} + +// getSchemeRolesForChannel resolves the generated role names of the scheme governing channelID's +// backing channel. Returns store.ErrNotFound when the channel does not exist or carries no scheme, +// so callers translate it with storeAppError/IsErrNotFound. +func (s *Service) getSchemeRolesForChannel(channelID string) (*schemeRoles, error) { + if channelID == "" { + return nil, &store.ErrInvalidInput{Entity: "Channel", Field: "id", Value: channelID} + } + + channel, err := s.client.Channel.GetChannelOfType(channelID, mmmodel.ChannelTypeSpace) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, &store.ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + return nil, err + } + return s.schemeRolesFromChannel(channelID, channel) +} + +// schemeRolesFromChannel is getSchemeRolesForChannel for a caller that already holds the backing +// channel. channelID identifies the channel in the returned not-found errors independently of what +// the channel object carries. +func (s *Service) schemeRolesFromChannel(channelID string, channel *mmmodel.Channel) (*schemeRoles, error) { + // The scheme reference is checked here rather than inferred from the resolved role names: core's + // RolesForChannel falls back to the team scheme's channel roles for a channel carrying no scheme + // of its own, and a space that lost its scheme must report not-found instead of silently + // resolving to team roles that grant no page capabilities. + if channel == nil || channel.SchemeId == nil || *channel.SchemeId == "" { + return nil, &store.ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + + guestRole, userRole, adminRole, err := s.client.Scheme.GetRolesForChannel(channelID) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, &store.ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + return nil, err + } + + return &schemeRoles{ + UserRoleName: userRole, + AdminRoleName: adminRole, + GuestRoleName: guestRole, + }, nil +} + +// isPresetSchemeID reports whether schemeID is one of the three seeded preset schemes, by resolving +// each reserved name to its id. Preset membership is settled by identity rather than by projecting +// the scheme's capability set, because a custom scheme whose generated roles were never given their +// permission sets still carries core's default channel baseline, which projects to the same empty +// set as the read-only preset. +func (s *Service) isPresetSchemeID(schemeID string) (bool, error) { + for _, name := range mmmodel.SpaceSchemeNames { + presetID, err := s.getSchemeIDByName(name) + if err != nil { + return false, err + } + if presetID == schemeID { + return true, nil + } + } + return false, nil +} + +// getSchemeIDByName returns the id of the scheme with the given name. +func (s *Service) getSchemeIDByName(name string) (string, error) { + scheme, err := s.client.Scheme.GetByName(name) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return "", &store.ErrNotFound{EntityName: "Scheme", ID: name} + } + return "", err + } + return scheme.Id, nil +} + +// getRolePermissionsByName returns the permission ids granted by the named role. +func (s *Service) getRolePermissionsByName(roleName string) ([]string, error) { + role, err := s.client.Role.GetByName(roleName) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, &store.ErrNotFound{EntityName: "Role", ID: roleName} + } + return nil, err + } + return role.Permissions, nil +} + +// createSpaceCustomScheme creates one space-private channel scheme and returns its id along with +// the three roles core generated for it. Those roles start on the default channel baseline; they +// are given their exact permission sets later by configureSpaceCustomScheme, which cannot run until +// a space backing channel points at the scheme. Until the caller attaches it, the scheme is +// referenced by nothing, so a caller whose next step fails retires it directly. +func (s *Service) createSpaceCustomScheme() (string, *schemeRoles, error) { + scheme, err := s.client.Scheme.Create(&mmmodel.Scheme{ + Name: spaceCustomSchemeNamePrefix + mmmodel.NewId(), + DisplayName: spaceCustomSchemeDisplayName, + Scope: mmmodel.SchemeScopeChannel, + }) + if err != nil { + return "", nil, err + } + return scheme.Id, &schemeRoles{ + UserRoleName: scheme.DefaultChannelUserRole, + AdminRoleName: scheme.DefaultChannelAdminRole, + GuestRoleName: scheme.DefaultChannelGuestRole, + }, nil +} + +// configureSpaceCustomScheme replaces the permission sets of the three roles generated for a +// space-private custom scheme, so the space's members hold exactly capabilities plus the baseline +// read. roles are the names createSpaceCustomScheme returned, so the writes land on the scheme that +// was created rather than on whatever a channel currently resolves to. +// +// It must run only once a space backing channel already points at that scheme: core admits a role +// write carrying space permissions for a seeded preset's roles, or for a scheme a space backing +// channel already references, and it does not accept a caller-chosen scheme name as proof. +func (s *Service) configureSpaceCustomScheme(roles *schemeRoles, capabilities []string) error { + capabilities = model.NormalizeCapabilitySet(capabilities) + roleSets := []struct { + name string + perms []string + }{ + {roles.UserRoleName, append([]string{model.CapabilityReadPage}, capabilities...)}, + {roles.AdminRoleName, mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions)}, + {roles.GuestRoleName, []string{model.CapabilityReadPage}}, + } + for _, rs := range roleSets { + if err := s.setRolePermissions(rs.name, rs.perms); err != nil { + return err + } + } + return nil +} + +// setRolePermissions replaces the named role's permission set with permissions. Returns +// store.ErrNotFound for a missing role, matching getRolePermissionsByName's translation of the same +// lookup so both surface the same status through storeAppError. +func (s *Service) setRolePermissions(roleName string, permissions []string) error { + role, err := s.client.Role.GetByName(roleName) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return &store.ErrNotFound{EntityName: "Role", ID: roleName} + } + return err + } + if _, err = s.client.Role.Patch(role, &mmmodel.RolePatch{Permissions: &permissions}); err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return &store.ErrNotFound{EntityName: "Role", ID: roleName} + } + return err + } + return nil +} + +// retireSpaceCustomScheme deletes a space-private custom scheme once its space no longer uses it. +// Core refuses to delete a seeded preset, and refuses any scheme a space backing channel still +// references, so a caller must have repointed the channel to another scheme first; the abandon path +// uses detachAndDeleteCustomScheme instead. A scheme already gone is a no-op. +func (s *Service) retireSpaceCustomScheme(schemeID string) error { + if _, err := s.client.Scheme.Delete(schemeID); err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil + } + return err + } + return nil +} + +// detachAndDeleteCustomScheme retires the custom scheme a failed CreateSpace left behind. The +// doomed backing channel still points at the scheme, and core counts even a soon-to-be-archived +// space channel as a live reference that blocks the delete, so the channel's scheme reference is +// cleared first through Channel.Update and only then is the now-unreferenced scheme deleted. +// +// Best-effort: any failure is logged, since the caller is already unwinding an earlier error. +func (s *Service) detachAndDeleteCustomScheme(channelID, schemeID string) { + channel, err := s.client.Channel.GetChannelOfType(channelID, mmmodel.ChannelTypeSpace) + switch { + case errors.Is(err, pluginapi.ErrNotFound): + // No channel row exists, so nothing references the scheme and the delete below succeeds + // without a detach. + case err != nil: + s.log.Error("failed to load abandoned backing channel to detach its custom scheme; the scheme may leak and must be deleted manually", "channel_id", channelID, "scheme_id", schemeID, "err", err) + return + case channel != nil && channel.SchemeId != nil && *channel.SchemeId == schemeID: + channel.SchemeId = nil + if updErr := s.client.Channel.Update(channel); updErr != nil { + s.log.Error("failed to detach custom scheme from abandoned backing channel; the scheme may leak and must be deleted manually", "channel_id", channelID, "scheme_id", schemeID, "err", updErr) + return + } + } + + if err := s.retireSpaceCustomScheme(schemeID); err != nil { + s.log.Error("failed to retire space custom scheme after a failed create; it must be deleted manually", "scheme_id", schemeID, "err", err) + } +} diff --git a/server/app/space.go b/server/app/space.go index 41db2b4..aa91358 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -81,11 +81,12 @@ func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel. } // hasOtherAuthorizedMemberMatching reports whether space has at least one backing-channel member -// other than excludeUserID that satisfies matches and can still reach the space — for a team -// space, one who is still an active member of the team. Former team members keep their -// channel-member rows after leaving the team, so counting raw rows would let the last reachable -// member be removed and leave the space stranded behind members who all fail the team half of the -// access gate. Iteration stops at the first match. +// other than excludeUserID that satisfies matches and can still reach the space — one who is also +// an active member of the space's team. Former team members keep their channel-member rows after +// leaving the team, so counting raw rows would let the last reachable member be removed and leave +// the space stranded behind members who all fail the team half of the access gate. Iteration stops +// at the first match. The no-team branch below is unreachable through CreateSpace, which requires +// a team id. func (s *Service) hasOtherAuthorizedMemberMatching(space *model.Space, excludeUserID string, matches func(cm *mmmodel.ChannelMember) bool) (bool, error) { found := false err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { @@ -120,19 +121,15 @@ func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID str // archiveOrphanChannel archives a backing channel when a later step in space creation fails, // to avoid an orphaned channel. reason describes the step that failed; cause is its error. -// Reports whether the channel was archived, which decides whether the same failure path may -// exclude it from the custom scheme's reference count (see cleanupCustomScheme). -func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) bool { +func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) { if s.client == nil { - return false + return } if delErr := s.client.Channel.Delete(channelID); delErr != nil { // The channel now exists with no space row pointing at it and nothing will retry this // archive, so it needs an operator to clean it up: log at Error, not Warn. s.log.Error("compensating channel archive failed; channel is orphaned and must be archived manually", "channel_id", channelID, "failure_reason", reason, "cause_err", cause, "delete_err", delErr) - return false } - return true } // resolveSpaceScheme picks the backing-channel scheme that gives a space's plain members the @@ -140,56 +137,51 @@ func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) bo // scheme, which is shared by every space using the same preset. Any other set gets a scheme // created here and used by this space alone. // -// createdCustom tells those two cases apart, so a caller whose next create step fails knows -// whether there is a scheme of its own to retire. -func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, createdCustom bool, err error) { +// A custom scheme is returned with its generated roles still carrying core's default channel +// baseline; the caller gives them their exact permission sets through configureSpaceCustomScheme +// once a backing channel points at the scheme, which is what lets core admit the role writes. +// +// customRoles is non-nil only in the second case, and names the roles that configure step must +// write. Its nil-ness also tells the two cases apart when a later step fails: a scheme created here +// is referenced by nothing else, so the caller must delete it (see cleanupCustomScheme), while a +// preset scheme is shared with other spaces and must be left alone. +func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, customRoles *schemeRoles, err error) { // Normalize before the permission set is persisted: the validators are dedup-tolerant, so // without this a request repeating one allowlisted token would write that repetition verbatim // into the generated role's Permissions column. capabilities = model.NormalizeCapabilitySet(capabilities) if presetName, ok := model.SchemeNameForDefaultCapabilities(capabilities); ok { - id, getErr := s.store.GetSchemeIDByName(presetName) + id, getErr := s.getSchemeIDByName(presetName) if getErr != nil { - return "", false, getErr + return "", nil, getErr } - return id, false, nil - } - userPerms := append([]string{model.CapabilityReadPage}, capabilities...) - adminPerms := mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions) - guestPerms := []string{model.CapabilityReadPage} - id, createErr := s.store.CreateSpaceCustomScheme(userPerms, adminPerms, guestPerms) - if createErr != nil { - return "", false, createErr + return id, nil, nil } - return id, true, nil + return s.createSpaceCustomScheme() } -// cleanupCustomScheme best-effort retires schemeID when createdCustom is true and a later -// create-step fails. abandonedChannelID, when non-empty, is the backing channel the same failure -// path just archived: archiving keeps the channel's SchemeId, so it must be excluded from the -// store's reference count or the retire would find the scheme still referenced and silently skip. -// It must be left empty when the archive did not succeed — excluding a channel that is still live -// would retire a scheme that channel still points at, leaving it with a dangling SchemeId. -func (s *Service) cleanupCustomScheme(schemeID string, createdCustom bool, abandonedChannelID string) { +// cleanupCustomScheme best-effort retires schemeID when createdCustom is true and a create-step +// fails before any channel references the scheme (channel create never happened, or the repoint +// onto it failed), so the scheme is already unreferenced and DeleteScheme accepts it directly. +func (s *Service) cleanupCustomScheme(schemeID string, createdCustom bool) { if !createdCustom { return } - if err := s.store.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, abandonedChannelID); err != nil { - s.log.Warn("failed to retire unreferenced space custom scheme after a failed create", "scheme_id", schemeID, "err", err) + if err := s.retireSpaceCustomScheme(schemeID); err != nil { + s.log.Error("failed to retire space custom scheme after a failed create; it must be deleted manually", "scheme_id", schemeID, "err", err) } } // abandonBackingChannel runs the compensating cleanup shared by every CreateSpace failure that -// happens after the backing channel exists: archive the channel, then retire the custom scheme it -// was created for. The scheme is only excluded from the reference count when the archive actually -// succeeded, so a channel left live by a failed archive still pins its scheme. +// happens after the backing channel exists: retire the custom scheme it was created for, then +// archive the channel. The scheme retirement clears the channel's scheme reference first, because +// archiving alone would not clear it and core still counts an archived space channel as a live +// reference that blocks the delete (see detachAndDeleteCustomScheme). func (s *Service) abandonBackingChannel(channelID, reason string, cause error, schemeID string, createdCustom bool) { - archived := s.archiveOrphanChannel(channelID, reason, cause) - excluded := "" - if archived { - excluded = channelID + if createdCustom { + s.detachAndDeleteCustomScheme(channelID, schemeID) } - s.cleanupCustomScheme(schemeID, createdCustom, excluded) + s.archiveOrphanChannel(channelID, reason, cause) } // CreateSpace creates a ChannelTypeSpace ("S") backing channel via pluginapi, saves the @@ -272,10 +264,11 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili return nil, capErr } - schemeID, createdCustom, schemeErr := s.resolveSpaceScheme(capabilities) + schemeID, customRoles, schemeErr := s.resolveSpaceScheme(capabilities) if schemeErr != nil { return nil, storeAppError("CreateSpace", schemeErr) } + createdCustom := customRoles != nil s.log.Debug("Creating space", "team_id", space.TeamId, "user_id", userID) @@ -295,11 +288,21 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili s.abandonBackingChannel(backingChannel.Id, "channel create failed after creation", err, schemeID, createdCustom) } else { // No channel row exists, so nothing references the scheme. - s.cleanupCustomScheme(schemeID, createdCustom, "") + s.cleanupCustomScheme(schemeID, createdCustom) } return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.backing_channel_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + // Only now that the backing channel points at the scheme will core admit role writes carrying + // space permissions, so a freshly created custom scheme gets its exact permission sets here + // rather than at create time. + if createdCustom { + if cfgErr := s.configureSpaceCustomScheme(customRoles, capabilities); cfgErr != nil { + s.abandonBackingChannel(backingChannel.Id, "custom scheme role configuration failed", cfgErr, schemeID, createdCustom) + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) + } + } + if _, addErr := s.client.Channel.AddMember(backingChannel.Id, userID); addErr != nil { // A space whose creator is not a member of its backing channel is a dead-end once per-space // membership gating lands (unreachable to everyone, creator included), so fail the create @@ -312,12 +315,12 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili // literals: on a scheme-backed channel core rejects the literal channel_user/channel_admin // tokens. The base user-role token is required, not optional (core resets all scheme flags and // rejects a string that leaves SchemeUser unset). - schemeRoles, rolesErr := s.store.GetSchemeRolesForChannel(backingChannel.Id) + resolvedRoles, rolesErr := s.getSchemeRolesForChannel(backingChannel.Id) if rolesErr != nil { s.abandonBackingChannel(backingChannel.Id, "scheme role lookup failed", rolesErr, schemeID, createdCustom) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(rolesErr) } - if _, roleErr := s.client.Channel.UpdateChannelMemberRoles(backingChannel.Id, userID, schemeRoles.UserRoleName+" "+schemeRoles.AdminRoleName); roleErr != nil { + if _, roleErr := s.client.Channel.UpdateChannelMemberRoles(backingChannel.Id, userID, resolvedRoles.UserRoleName+" "+resolvedRoles.AdminRoleName); roleErr != nil { s.abandonBackingChannel(backingChannel.Id, "creator admin role assignment failed", roleErr, schemeID, createdCustom) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.admin_role_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(roleErr) } @@ -360,11 +363,21 @@ func (s *Service) GetSpaceWithDeleted(spaceID string) (*model.Space, *mmmodel.Ap } // spaceDefaultCapabilities returns space's current default capability set in wire form -// (read_page-free): preset recognition by the backing channel's scheme name, or — for a -// space-private custom scheme — the generated user role's stored permission set projected onto -// the capability vocabulary. +// (read_page-free): the generated user role's stored permission set projected onto the capability +// vocabulary. The projection covers presets and space-private custom schemes alike, since a +// preset's generated user role carries exactly that preset's capabilities. func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) { - roles, err := s.store.GetSchemeRolesForChannel(space.ChannelId) + roles, err := s.getSchemeRolesForChannel(space.ChannelId) + if err != nil { + return nil, err + } + return s.defaultCapabilitiesForRoles(roles) +} + +// spaceDefaultCapabilitiesFromChannel is spaceDefaultCapabilities for a caller that already holds +// the backing channel. +func (s *Service) spaceDefaultCapabilitiesFromChannel(channelID string, channel *mmmodel.Channel) ([]string, error) { + roles, err := s.schemeRolesFromChannel(channelID, channel) if err != nil { return nil, err } @@ -373,11 +386,8 @@ func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) // defaultCapabilitiesForRoles is spaceDefaultCapabilities for a caller that already holds the // backing channel's scheme roles. -func (s *Service) defaultCapabilitiesForRoles(roles *store.SchemeRoles) ([]string, error) { - if capabilities, ok := model.DefaultCapabilitiesForSchemeName(roles.SchemeName); ok { - return capabilities, nil - } - perms, err := s.store.GetRolePermissionsByName(roles.UserRoleName) +func (s *Service) defaultCapabilitiesForRoles(roles *schemeRoles) ([]string, error) { + perms, err := s.getRolePermissionsByName(roles.UserRoleName) if err != nil { return nil, err } @@ -455,26 +465,29 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.channel_scheme_missing.app_error", nil, "", http.StatusInternalServerError) } currentSchemeID := *channel.SchemeId - // Read the superseded scheme's name before the repoint below rewrites which scheme the - // channel points at. A failure here only costs the retirement step, so it is carried as a - // value and handled after the repoint rather than failing the whole operation. - currentRoles, currentRolesErr := s.store.GetSchemeRolesForChannel(space.ChannelId) - - // Compare against the live default before resolving a scheme. resolveSpaceScheme returns a - // pre-existing id only for the three presets — a non-preset set always creates a fresh - // scheme — so without this an unchanged custom set would create, repoint, and retire on - // every save, and the id comparison below could never catch it. - if currentRolesErr == nil { - if liveCapabilities, capsErr := s.defaultCapabilitiesForRoles(currentRoles); capsErr == nil && - slices.Equal(liveCapabilities, model.NormalizeCapabilitySet(capabilities)) { - return nil - } + // Project the superseded scheme's capability set from the channel already in hand, before + // the repoint below rewrites which scheme the channel points at. A failure here only costs + // the no-op shortcut, so it is carried as a value rather than failing the whole operation. + liveCapabilities, liveCapabilitiesErr := s.spaceDefaultCapabilitiesFromChannel(space.ChannelId, channel) + + requested := model.NormalizeCapabilitySet(capabilities) + _, requestedIsPreset := model.SchemeNameForDefaultCapabilities(requested) + + // A non-preset set always mints a fresh scheme, so the id comparison below could never + // recognize an unchanged custom set; compare the projected capabilities instead. A preset + // request is left to that id comparison, which settles it by scheme identity — the + // projection cannot, because a custom scheme whose roles were never configured projects to + // the same empty set as the read-only preset, and shortcutting there would strand the space + // on that unconfigured scheme with no way to move off it. + if !requestedIsPreset && liveCapabilitiesErr == nil && slices.Equal(liveCapabilities, requested) { + return nil } - targetSchemeID, createdCustom, schemeErr := s.resolveSpaceScheme(capabilities) + targetSchemeID, customRoles, schemeErr := s.resolveSpaceScheme(capabilities) if schemeErr != nil { return storeAppError("SetSpaceDefaultCapabilities", schemeErr) } + createdCustom := customRoles != nil if targetSchemeID == currentSchemeID { // No-op: requested set already matches the live default. return nil @@ -482,20 +495,40 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ channel.SchemeId = &targetSchemeID if updErr := s.client.Channel.Update(channel); updErr != nil { - s.cleanupCustomScheme(targetSchemeID, createdCustom, "") + s.cleanupCustomScheme(targetSchemeID, createdCustom) return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.repoint_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) } + // The repoint above is what lets core admit role writes carrying space permissions, so a + // freshly created custom scheme is configured only now. A failure leaves the space on a + // scheme whose roles still hold core's default channel baseline, so the repoint is undone + // before the unusable scheme is retired. + if createdCustom { + if cfgErr := s.configureSpaceCustomScheme(customRoles, capabilities); cfgErr != nil { + channel.SchemeId = ¤tSchemeID + if rollbackErr := s.client.Channel.Update(channel); rollbackErr != nil { + s.log.Error("failed to restore the previous space scheme after a failed custom-scheme configuration; the space is left on an unconfigured scheme", "channel_id", space.ChannelId, "scheme_id", targetSchemeID, "previous_scheme_id", currentSchemeID, "err", rollbackErr) + } else { + s.cleanupCustomScheme(targetSchemeID, createdCustom) + } + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) + } + } + // Retire the superseded scheme, but only inside this lock (a concurrent repoint between an // outside-the-lock read and the retire call could otherwise delete a scheme a racing // SetSpaceDefaultCapabilities just repointed away from) and only when it was a // space-private custom one — a routine preset-to-preset switch must neither call the - // retire path nor log a spurious failure Warn. - if currentRolesErr != nil { - s.log.Warn("failed to determine whether the superseded space scheme is a preset; skipping custom-scheme retirement", "scheme_id", currentSchemeID, "err", currentRolesErr) - } else if _, isPreset := model.DefaultCapabilitiesForSchemeName(currentRoles.SchemeName); !isPreset { - if delErr := s.store.DeleteSpaceCustomSchemeIfUnreferenced(currentSchemeID, ""); delErr != nil { - s.log.Warn("failed to retire unreferenced space custom scheme after a default-capabilities change", "scheme_id", currentSchemeID, "err", delErr) + // retire path nor log a spurious failure. Preset membership is settled by scheme id rather + // than by the projected capability set, which cannot tell a preset apart from a custom + // scheme whose roles were never given their permission sets. + supersededIsPreset, presetErr := s.isPresetSchemeID(currentSchemeID) + switch { + case presetErr != nil: + s.log.Warn("failed to determine whether the superseded space scheme is a preset; skipping custom-scheme retirement", "scheme_id", currentSchemeID, "err", presetErr) + case !supersededIsPreset: + if delErr := s.retireSpaceCustomScheme(currentSchemeID); delErr != nil { + s.log.Error("failed to retire space custom scheme after a default-capabilities change; it must be deleted manually", "scheme_id", currentSchemeID, "err", delErr) } } return nil diff --git a/server/app/space_members.go b/server/app/space_members.go index b4a8d0a..877f3ca 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -12,7 +12,6 @@ import ( "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/model" - "github.com/mattermost/mattermost-plugin-docs/server/store" ) // GetSpaceMembers returns one page of space's members plus whether more members exist beyond @@ -154,15 +153,15 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st var appErr *mmmodel.AppError var newRoles string var newSchemeAdmin bool - var schemeRoles *store.SchemeRoles + var resolvedRoles *schemeRoles lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { var rolesErr error - schemeRoles, rolesErr = s.store.GetSchemeRolesForChannel(space.ChannelId) + resolvedRoles, rolesErr = s.getSchemeRolesForChannel(space.ChannelId) if rolesErr != nil { appErr = storeAppError("SetSpaceMemberCapabilities", rolesErr) return appErr } - newRoles, newSchemeAdmin = model.RolesForCapabilities(capabilities, schemeRoles.UserRoleName) + newRoles, newSchemeAdmin = model.RolesForCapabilities(capabilities, resolvedRoles.UserRoleName) target, memErr := s.client.Channel.GetMember(space.ChannelId, targetUserID) if memErr != nil { @@ -200,7 +199,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st roles := newRoles if newSchemeAdmin { - roles = roles + " " + schemeRoles.AdminRoleName + roles = roles + " " + resolvedRoles.AdminRoleName } if _, updErr := s.client.Channel.UpdateChannelMemberRoles(space.ChannelId, targetUserID, roles); updErr != nil { appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.update_capabilities_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) @@ -218,7 +217,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st // The scheme roles read under the lock still describe this channel: only // SetSpaceDefaultCapabilities repoints a channel's scheme, and it serializes behind the same // space-keyed lock, so the default capability set is projected from them rather than re-read. - defaultCapabilities, defErr := s.defaultCapabilitiesForRoles(schemeRoles) + defaultCapabilities, defErr := s.defaultCapabilitiesForRoles(resolvedRoles) if defErr != nil { return nil, storeAppError("SetSpaceMemberCapabilities", defErr) } diff --git a/server/app/space_test.go b/server/app/space_test.go index a2d5728..0cbcda0 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -31,6 +31,7 @@ func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness t.Helper() h := openTestService(t) testutil.StubDefaultSpacePermissions(mockAPI) + testutil.StubPresetSchemes(mockAPI) mockAPI.On("GetConfig").Return(&mmmodel.Config{}).Maybe() // Mutations publish best-effort WS events through the client; tests that assert event // content override this with exact-argument expectations. @@ -56,8 +57,9 @@ func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness // LogError with message plus four (archiveOrphanChannel). mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() - // LogError shapes: message plus three pairs (UpdateSpace's channel-metadata sync failure) - // and message plus four pairs (archiveOrphanChannel). + // LogError shapes: message plus two pairs (the custom-scheme retire failures), plus three + // pairs (UpdateSpace's channel-metadata sync failure) and plus four (archiveOrphanChannel). + mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() // CreateSpace assigns the creator SchemeAdmin via the scheme's resolved role-name string; no @@ -78,7 +80,7 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.MatchedBy(func(ch *mmmodel.Channel) bool { return ch.Type == mmmodel.ChannelTypeSpace && ch.TeamId == teamID })).Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) @@ -139,6 +141,7 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { mockAPI := &plugintest.API{} h := openTestService(t) testutil.StubDefaultSpacePermissions(mockAPI) + testutil.StubPresetSchemes(mockAPI) mockAPI.On("GetConfig"). Return(&mmmodel.Config{SqlSettings: mmmodel.SqlSettings{DataSourceReplicas: []string{"replica"}}}).Maybe() mockAPI.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() @@ -148,7 +151,7 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -273,7 +276,7 @@ func TestServiceDeleteSpace_ArchivesBackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -303,7 +306,7 @@ func TestServiceDeleteSpace_ArchiveFailureTolerated(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -337,7 +340,12 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + // The channel is still archived, so the un-archive genuinely failed and must propagate. + // Set on the shared channel MustSeedChannelScheme's GetChannelOfType stub returns, rather than + // a second competing stub: DeleteAt is irrelevant to CreateSpace's own scheme-resolution read + // of the same channel, so it can be set from the start. + channel.DeleteAt = 100 mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -345,9 +353,6 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("RestoreChannel", backingChannelID). Return(&mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) - // The channel is still archived, so the un-archive genuinely failed and must propagate. - mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). - Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 100}, nil) space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) @@ -377,7 +382,11 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + // The channel stays archived throughout, so both the failure check and the retry see it as + // genuinely needing an un-archive. Set on the shared channel MustSeedChannelScheme's + // GetChannelOfType stub returns, rather than a second competing stub. + channel.DeleteAt = 100 mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -387,10 +396,6 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { mockAPI.On("RestoreChannel", backingChannelID). Return(&mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}).Once() mockAPI.On("RestoreChannel", backingChannelID).Return(nil).Once() - // The channel stays archived throughout, so both the failure check and the retry see it as - // genuinely needing an un-archive. - mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). - Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 100}, nil) space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Stuck Restore"}, userID, nil, nil) require.Nil(t, appErr) @@ -421,7 +426,9 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + // MustSeedChannelScheme's shared channel defaults to DeleteAt 0 (live), matching this test's + // "the channel stays live" intent — no separate GetChannelOfType stub needed. + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -432,8 +439,6 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { // Core rejects un-archiving a channel that was never archived. mockAPI.On("RestoreChannel", backingChannelID). Return(&mmmodel.AppError{Message: "channel is not archived", StatusCode: http.StatusBadRequest}) - mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). - Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 0}, nil) space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Never Archived"}, userID, nil, nil) require.Nil(t, appErr) @@ -462,7 +467,7 @@ func TestServiceCreateSpace_CompensatingDelete(t *testing.T) { // trips the unique channel-id constraint and the row save fails. mustCreateSpace(t, h.store, h.db, collisionChannelID) - testutil.MustSeedChannelScheme(t, h.db, collisionChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, collisionChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: collisionChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", collisionChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -491,7 +496,7 @@ func TestServiceCreateSpace_CompensatingDeleteAlsoFails(t *testing.T) { // trips the unique channel-id constraint and the row save fails. mustCreateSpace(t, h.store, h.db, collisionChannelID) - testutil.MustSeedChannelScheme(t, h.db, collisionChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, collisionChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: collisionChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", collisionChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -516,7 +521,7 @@ func TestServiceCreateSpace_AddMemberFailedCompensates(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID). @@ -537,6 +542,252 @@ func TestServiceCreateSpace_AddMemberFailedCompensates(t *testing.T) { require.Empty(t, spaces) } +// stubCustomSchemeCreate wires the mock calls a non-preset default-capability set needs: a +// CreateScheme returning a scheme with three generated roles, those roles registered so a channel +// repointed at the scheme resolves them, and a PatchRole that applies the plugin's writes. Returns +// the new scheme's id. +func stubCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { + t.Helper() + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "custom_user_role", "custom_admin_role", "custom_guest_role" + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: "docs_space_custom_" + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + testutil.StubPatchRole(mockAPI) + return schemeID +} + +// TestServiceCreateSpace_CustomSchemeAbandonedOnLaterFailure covers the compensating path a +// non-preset default-capability set takes when a later create step fails: the custom scheme must be +// detached from the doomed backing channel BEFORE it is deleted, because core counts even a +// soon-to-be-archived space channel as a live reference that refuses the delete. A preset-backed +// create never reaches this path, so without a non-preset set the whole branch goes unexercised. +func TestServiceCreateSpace_CustomSchemeAbandonedOnLaterFailure(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + customSchemeID := stubCustomSchemeCreate(t, mockAPI) + // CreateChannel attaches the custom scheme, which is what lets core admit the role writes. + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + created, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, created.SchemeId) + channel.SchemeId = created.SchemeId + }). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("AddChannelMember", backingChannelID, userID). + Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) + mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, nil) + mockAPI.On("DeleteChannel", backingChannelID).Return(nil) + + _, appErr := h.svc.CreateSpace( + &model.Space{TeamId: teamID, Title: "Doomed Custom"}, userID, &[]string{"create_page"}, nil) + require.NotNil(t, appErr) + require.Equal(t, "app.space.create.add_member_failed.app_error", appErr.Id) + + require.Nil(t, channel.SchemeId, "the doomed channel's scheme reference must be cleared before the scheme is deleted") + mockAPI.AssertCalled(t, "DeleteScheme", customSchemeID) + mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) +} + +// TestServiceCreateSpace_PresetSchemeSurvivesAbandon is the negative half of the case above: a +// preset scheme is shared by every space using it, so a failed create must archive the channel +// without ever deleting the scheme. +func TestServiceCreateSpace_PresetSchemeSurvivesAbandon(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("AddChannelMember", backingChannelID, userID). + Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) + mockAPI.On("DeleteChannel", backingChannelID).Return(nil) + + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed Preset"}, userID, nil, nil) + require.NotNil(t, appErr) + mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) +} + +// TestServiceCreateSpace_CustomSchemeConfiguredAfterChannelAttach verifies the ordering core +// requires: the role writes carrying space permissions land only after the backing channel already +// points at the scheme, since a caller-chosen scheme name is not accepted as proof of space scope. +func TestServiceCreateSpace_CustomSchemeConfiguredAfterChannelAttach(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + + // Registered before stubCustomSchemeCreate's own catch-all PatchRole so this one matches first + // (mock.Mock matches in registration order) and can record the permission set per role. + channelAttached := false + patched := map[string][]string{} + mockAPI.On("PatchRole", mock.AnythingOfType("*model.Role"), mock.AnythingOfType("*model.RolePatch")). + Run(func(args mock.Arguments) { + require.True(t, channelAttached, "roles must be patched only after the channel attaches the scheme") + role, ok := args.Get(0).(*mmmodel.Role) + require.True(t, ok) + patch, ok := args.Get(1).(*mmmodel.RolePatch) + require.True(t, ok) + require.NotNil(t, patch.Permissions) + patched[role.Name] = *patch.Permissions + }). + Return(&mmmodel.Role{}, nil) + + customSchemeID := stubCustomSchemeCreate(t, mockAPI) + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + created, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, created.SchemeId) + require.Equal(t, customSchemeID, *created.SchemeId) + channel.SchemeId = created.SchemeId + channelAttached = true + }). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) + + space, appErr := h.svc.CreateSpace( + &model.Space{TeamId: teamID, Title: "Custom Caps"}, userID, &[]string{"create_page"}, nil) + require.Nil(t, appErr) + require.NotNil(t, space) + + // Each generated role gets its own set: the user role the requested capabilities plus the + // baseline read, the guest role read alone, and the admin role the full space-admin set. + require.ElementsMatch(t, []string{"read_page", "create_page"}, patched["custom_user_role"]) + require.ElementsMatch(t, []string{"read_page"}, patched["custom_guest_role"]) + require.ElementsMatch(t, mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions), patched["custom_admin_role"]) +} + +// stubCustomSchemeCreateFailingPatch is stubCustomSchemeCreate with a PatchRole that always fails, +// so the configure step a freshly created custom scheme needs cannot complete. Returns the new +// scheme's id. +func stubCustomSchemeCreateFailingPatch(t *testing.T, mockAPI *plugintest.API) string { + t.Helper() + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "unconfigurable_user_role", "unconfigurable_admin_role", "unconfigurable_guest_role" + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: "docs_space_custom_" + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + mockAPI.On("PatchRole", mock.AnythingOfType("*model.Role"), mock.AnythingOfType("*model.RolePatch")). + Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) + return schemeID +} + +// TestServiceCreateSpace_CustomSchemeConfigureFailureAbandons covers the CreateSpace branch that +// runs when the role writes fail after the backing channel already carries the new scheme: the +// create fails, and the same detach-then-delete-then-archive cleanup runs, so neither the scheme +// nor the channel is left behind. The creator is never added, since configuration precedes that. +func TestServiceCreateSpace_CustomSchemeConfigureFailureAbandons(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + customSchemeID := stubCustomSchemeCreateFailingPatch(t, mockAPI) + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + created, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, created.SchemeId) + channel.SchemeId = created.SchemeId + }). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, nil) + mockAPI.On("DeleteChannel", backingChannelID).Return(nil) + + _, appErr := h.svc.CreateSpace( + &model.Space{TeamId: teamID, Title: "Unconfigurable Custom"}, userID, &[]string{"create_page"}, nil) + require.NotNil(t, appErr) + require.Equal(t, "app.space.create.scheme_configure_failed.app_error", appErr.Id) + + require.Nil(t, channel.SchemeId, "the doomed channel's scheme reference must be cleared before the scheme is deleted") + mockAPI.AssertCalled(t, "DeleteScheme", customSchemeID) + mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) + + spaces, err := h.store.GetSpacesForTeam(teamID, userID, false, 0, 10) + require.NoError(t, err) + require.Empty(t, spaces) +} + +// TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack covers the repoint-then-configure +// failure branch: a space whose new custom scheme cannot be configured must be put back on its +// previous scheme rather than left on one whose roles still carry core's default channel baseline. +func TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack(t *testing.T) { + setup := func(t *testing.T, deleteSchemeErr *mmmodel.AppError) (*mmmodel.Channel, string, *mmmodel.AppError) { + t.Helper() + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + channel := testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + customSchemeID := stubCustomSchemeCreateFailingPatch(t, mockAPI) + mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, deleteSchemeErr) + + space := mustCreateSpace(t, h.store, h.db, channelID) + _, appErr := h.svc.SetSpaceDefaultCapabilities(space, []string{"create_page"}, mmmodel.NewId()) + return channel, customSchemeID, appErr + } + + t.Run("restores the previous scheme and retires the unconfigured one", func(t *testing.T) { + channel, customSchemeID, appErr := setup(t, nil) + + require.NotNil(t, appErr) + require.Equal(t, "app.space.default_capabilities.scheme_configure_failed.app_error", appErr.Id) + require.NotNil(t, channel.SchemeId) + require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, + "the channel must be repointed back at the scheme it started on") + require.NotEqual(t, customSchemeID, *channel.SchemeId) + }) + + t.Run("still reports the configure failure when the retire is refused", func(t *testing.T) { + channel, _, appErr := setup(t, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusBadRequest}) + + require.NotNil(t, appErr) + require.Equal(t, "app.space.default_capabilities.scheme_configure_failed.app_error", appErr.Id) + require.NotNil(t, channel.SchemeId) + require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, + "a refused retire must not leave the channel on the unconfigured scheme") + }) +} + // TestServiceRestoreSpace_UnarchivesBackingChannel verifies a create→delete→restore round trip // un-archives the backing channel and brings the space back live. func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { @@ -547,7 +798,7 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, backingChannelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -656,7 +907,7 @@ func TestGetSpaceWithDeleted(t *testing.T) { userID := mmmodel.NewId() channelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -724,7 +975,7 @@ func createSpaceForMemberTests(t *testing.T, h *testHarness, mockAPI *plugintest creatorID := mmmodel.NewId() channelID := mmmodel.NewId() - testutil.MustSeedChannelScheme(t, h.db, channelID, mmmodel.SchemeNameSpaceContribute) + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, creatorID).Return(&mmmodel.ChannelMember{}, nil) @@ -750,6 +1001,26 @@ func TestServiceGetSpaceMembers_ListFails(t *testing.T) { require.Equal(t, "app.space.list_members.failed.app_error", appErr.Id) } +// TestServiceDefaultRolesGrantPermission_ChannelWithoutScheme covers schemeRolesFromChannel's +// fail-closed branch: a backing channel carrying no scheme (a space that lost its scheme) resolves +// to not-found, which DefaultRolesGrantPermission maps to "not granted" — never a silent +// fall-through to the team scheme's channel roles, and never a wrong-role grant. The lookup must +// short-circuit before reaching GetSchemeRolesForChannel. +func TestServiceDefaultRolesGrantPermission_ChannelWithoutScheme(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + // The mock never sets a SchemeId, standing in for a space whose backing channel carries none. + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace). + Return(&mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace}, nil) + + granted, err := h.svc.DefaultRolesGrantPermission(&model.Space{ChannelId: channelID}, mmmodel.PermissionCreatePage) + require.NoError(t, err) + require.False(t, granted) + mockAPI.AssertNotCalled(t, "GetSchemeRolesForChannel", mock.Anything) +} + // TestServiceAddSpaceMember_AddFails verifies that a failed channel-member add propagates as a 500 // with the add_member error key. func TestServiceAddSpaceMember_AddFails(t *testing.T) { diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index a398b66..b0f294f 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -433,6 +433,64 @@ func TestScenarios(t *testing.T) { require.Equal(t, http.StatusForbidden, status, "control delete") }) + // scenario8 exercises the space-private CUSTOM scheme path — a default-capability set matching + // no preset — which the plugin provisions through core's CreateScheme + PatchRole plugin API. + // It asserts the whole write→read chain end-to-end against real core: the custom set round-trips + // through GET (proving PatchRole set exactly those role permissions and they project back), a + // plain member is enforced against it, and switching back to a preset retires the now-unreferenced + // custom scheme. + t.Run("scenario8_custom_capability_scheme", func(t *testing.T) { + customCaps := []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage} + + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Custom Capability Scheme", "default_capabilities": customCaps}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace with custom default caps failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + // The custom set must round-trip: create provisioned a docs_space_custom_* scheme whose user + // role PatchRole set to exactly {read_page}+customCaps, and GET projects that back. + var withAccess pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "get space: %s", body) + require.ElementsMatch(t, customCaps, withAccess.DefaultCapabilities, + "custom default caps did not round-trip (CreateScheme/PatchRole path): %s", body) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + + // A plain member holds exactly the custom default: create and edit, but not delete-own. + var page pluginmodel.Page + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Member custom-create"), &page) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "granted custom create_page: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+page.Id, + editPageReq(page.EditAt, "member custom-edit"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granted custom edit_page: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+page.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "custom default excludes delete_own_page: %s", body) + + // Switching to the read-only preset repoints the backing channel at the shared preset and + // retires the now-unreferenced custom scheme (DeleteScheme); the member loses create. + var roResp pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/default-capabilities", + map[string][]string{"default_capabilities": {}}, &roResp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "switch custom→preset failed: %s", body) + require.Empty(t, roResp.DefaultCapabilities, "default caps not cleared to read-only: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Should be forbidden now"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "member create after switch to read-only: %s", body) + }) + t.Run("gap_open_space_removal_not_durable", func(t *testing.T) { // Continues from scenario 1's open space, where OUTSIDER is already a member via auto-join. require.NotEmpty(t, s1ID, "scenario1 must have run first") diff --git a/server/internal/testutil/fixtures.go b/server/internal/testutil/fixtures.go index 1c588de..791ca30 100644 --- a/server/internal/testutil/fixtures.go +++ b/server/internal/testutil/fixtures.go @@ -5,7 +5,6 @@ package testutil import ( "database/sql" - "strings" "testing" "github.com/stretchr/testify/require" @@ -45,128 +44,9 @@ func OpenTestStore(t *testing.T) (*store.Store, *sql.DB) { )`) require.NoError(t, err, "create ChannelMembers stand-in") - // The RBAC capability model resolves a space's default capability set through its backing - // channel's core Channels/Schemes/Roles rows (GetSchemeRolesForChannel, GetSchemeIDByName, - // GetRolePermissionsByName) — core tables owned by the paired core branch's migration in - // production, absent from this plugin-only isolated schema. Stand them in with just the - // columns the plugin store queries, and seed the three preset schemes the core seeding - // migration creates in production, so scheme-resolving app methods work against test-created - // spaces. - mustCreateSchemeStandInTables(t, db) - mustSeedSpaceSchemes(t, db) - return s, db } -// mustCreateSchemeStandInTables creates the core Channels/Schemes/Roles stand-in tables with only -// the columns the plugin store reads or writes (see space_store.go/scheme_store.go). -func mustCreateSchemeStandInTables(t *testing.T, db *sql.DB) { - t.Helper() - _, err := db.Exec(`CREATE TABLE IF NOT EXISTS Channels ( - Id varchar(26) PRIMARY KEY, - SchemeId varchar(26) - )`) - require.NoError(t, err, "create Channels stand-in") - - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS Schemes ( - Id varchar(26) PRIMARY KEY, - Name varchar(64) UNIQUE NOT NULL, - DisplayName varchar(128) NOT NULL DEFAULT '', - Description varchar(1024) NOT NULL DEFAULT '', - Scope varchar(32) NOT NULL DEFAULT '', - DefaultTeamAdminRole varchar(64) NOT NULL DEFAULT '', - DefaultTeamUserRole varchar(64) NOT NULL DEFAULT '', - DefaultTeamGuestRole varchar(64) NOT NULL DEFAULT '', - DefaultChannelAdminRole varchar(64) NOT NULL DEFAULT '', - DefaultChannelUserRole varchar(64) NOT NULL DEFAULT '', - DefaultChannelGuestRole varchar(64) NOT NULL DEFAULT '', - CreateAt bigint NOT NULL DEFAULT 0, - UpdateAt bigint NOT NULL DEFAULT 0, - DeleteAt bigint NOT NULL DEFAULT 0, - DefaultPlaybookAdminRole varchar(64) NOT NULL DEFAULT '', - DefaultPlaybookMemberRole varchar(64) NOT NULL DEFAULT '', - DefaultRunAdminRole varchar(64) NOT NULL DEFAULT '', - DefaultRunMemberRole varchar(64) NOT NULL DEFAULT '' - )`) - require.NoError(t, err, "create Schemes stand-in") - - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS Roles ( - Id varchar(26) PRIMARY KEY, - Name varchar(64) UNIQUE NOT NULL, - DisplayName varchar(128) NOT NULL DEFAULT '', - Description varchar(1024) NOT NULL DEFAULT '', - Permissions text NOT NULL DEFAULT '', - CreateAt bigint NOT NULL DEFAULT 0, - UpdateAt bigint NOT NULL DEFAULT 0, - DeleteAt bigint NOT NULL DEFAULT 0, - SchemeManaged boolean NOT NULL DEFAULT false, - BuiltIn boolean NOT NULL DEFAULT false, - SchemeId varchar(26) - )`) - require.NoError(t, err, "create Roles stand-in") -} - -// mustSeedSpaceSchemes seeds the three preset space schemes (contribute/comment/read-only), each -// with a generated user/admin/guest role carrying the canonical permission sets — the same shape -// the paired core branch's core seeding migration creates in production. -func mustSeedSpaceSchemes(t *testing.T, db *sql.DB) { - t.Helper() - presets := []struct { - name string - user []*mmmodel.Permission - admin []*mmmodel.Permission - guest []*mmmodel.Permission - }{ - {mmmodel.SchemeNameSpaceContribute, mmmodel.SpaceDefaultContributePermissions, mmmodel.SpaceAdminRolePermissions, []*mmmodel.Permission{mmmodel.PermissionReadPage}}, - {mmmodel.SchemeNameSpaceComment, mmmodel.SpaceDefaultCommentPermissions, mmmodel.SpaceAdminRolePermissions, []*mmmodel.Permission{mmmodel.PermissionReadPage}}, - {mmmodel.SchemeNameSpaceReadOnly, mmmodel.SpaceDefaultReadOnlyPermissions, mmmodel.SpaceAdminRolePermissions, []*mmmodel.Permission{mmmodel.PermissionReadPage}}, - } - now := mmmodel.GetMillis() - for _, p := range presets { - userRoleName := mustInsertStandInRole(t, db, "User Role for "+p.name, p.user, now) - adminRoleName := mustInsertStandInRole(t, db, "Admin Role for "+p.name, p.admin, now) - guestRoleName := mustInsertStandInRole(t, db, "Guest Role for "+p.name, p.guest, now) - - _, err := db.Exec(`INSERT INTO Schemes - (Id, Name, Scope, DefaultChannelUserRole, DefaultChannelAdminRole, DefaultChannelGuestRole, CreateAt, UpdateAt) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`, - mmmodel.NewId(), p.name, mmmodel.SchemeScopeChannel, userRoleName, adminRoleName, guestRoleName, now) - require.NoError(t, err, "seed space scheme %q", p.name) - } -} - -// mustInsertStandInRole inserts one generated role row carrying permissions, returning its -// (randomly generated) name — mirroring core's scheme-generated role shape closely enough for the -// plugin store's Permissions-column parsing (space-joined string, strings.Fields on read). -func mustInsertStandInRole(t *testing.T, db *sql.DB, displayName string, permissions []*mmmodel.Permission, now int64) string { - t.Helper() - ids := make([]string, 0, len(permissions)) - for _, p := range permissions { - ids = append(ids, p.Id) - } - roleName := mmmodel.NewId() - _, err := db.Exec(`INSERT INTO Roles (Id, Name, DisplayName, Permissions, CreateAt, UpdateAt, SchemeManaged, BuiltIn) - VALUES ($1, $2, $3, $4, $5, $5, true, false)`, - mmmodel.NewId(), roleName, displayName, " "+strings.Join(ids, " "), now) - require.NoError(t, err, "seed stand-in role %q", displayName) - return roleName -} - -// MustSeedChannelScheme seeds (or repoints) a Channels stand-in row so channelID resolves to the -// named seeded space scheme preset (see model.SpaceSchemeNames) — production sets this via the -// real Channels table when CreateSpace/SetSpaceDefaultCapabilities points a space's backing -// channel at a scheme; tests that mock the backing channel (CreateChannel via plugintest) need -// this seeded explicitly, since the mock never writes to a real Channels table. -func MustSeedChannelScheme(t *testing.T, db *sql.DB, channelID, schemeName string) { - t.Helper() - var schemeID string - err := db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, schemeName).Scan(&schemeID) - require.NoError(t, err, "look up seeded scheme %q", schemeName) - _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2) - ON CONFLICT (Id) DO UPDATE SET SchemeId = EXCLUDED.SchemeId`, channelID, schemeID) - require.NoError(t, err, "seed Channels stand-in for %q", channelID) -} - // MustAddChannelMember seeds a ChannelMembers row (see the stand-in table in OpenTestStore) so // store queries that resolve visibility through channel membership can see channelID as userID. func MustAddChannelMember(t *testing.T, db *sql.DB, channelID, userID string) { @@ -209,16 +89,6 @@ func MustCreateSpace(t *testing.T, s *store.Store, channelID, teamID string) *mo return space } -// MustCreateSpaceWithScheme is MustCreateSpace plus a matching Channels stand-in row pointed at -// the contribute preset scheme, mirroring production CreateSpace's default so scheme-resolving -// paths (spaceDefaultCapabilities, GetSchemeRolesForChannel) work against the space's channel. -func MustCreateSpaceWithScheme(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { - t.Helper() - space := MustCreateSpace(t, s, channelID, teamID) - MustSeedChannelScheme(t, db, channelID, mmmodel.SchemeNameSpaceContribute) - return space -} - // MustCreatePage saves the standard page fixture (NewPage) through the store, failing the test // on error. The depth cap is bypassed (UncappedMaxDepth): some callers build chains deeper than // the app-layer cap to exercise store.MaxPageHierarchyDepth instead. diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go new file mode 100644 index 0000000..46d9791 --- /dev/null +++ b/server/internal/testutil/schemestub.go @@ -0,0 +1,247 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package testutil + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" +) + +// presetSchemeFixture is the fixed identity and role/permission shape stubbed for one of the three +// seeded preset space schemes (see mmmodel.SpaceSchemeNames) — mirroring the paired core branch's +// core seeding migration, which creates these same three schemes with generated per-scheme roles +// in production. +type presetSchemeFixture struct { + schemeID string + userRole string + adminRole string + guestRole string + userPerms []*mmmodel.Permission + adminPerms []*mmmodel.Permission + guestPerms []*mmmodel.Permission +} + +// presetSchemeFixtures holds one fixture per preset name, keyed by the scheme's Name. Scheme ids +// are fixed 26-char valid model ids (mmmodel.IsValidId) so MustSeedChannelScheme and +// StubPresetSchemes agree on which id a preset resolves to. +var presetSchemeFixtures = map[string]presetSchemeFixture{ + mmmodel.SchemeNameSpaceContribute: { + schemeID: "contributescheme0000000000", + userRole: "space_contribute_user_role", + adminRole: "space_contribute_admin_role", + guestRole: "space_contribute_guest_role", + userPerms: mmmodel.SpaceDefaultContributePermissions, + adminPerms: mmmodel.SpaceAdminRolePermissions, + guestPerms: []*mmmodel.Permission{mmmodel.PermissionReadPage}, + }, + mmmodel.SchemeNameSpaceComment: { + schemeID: "commentscheme0000000000000", + userRole: "space_comment_user_role", + adminRole: "space_comment_admin_role", + guestRole: "space_comment_guest_role", + userPerms: mmmodel.SpaceDefaultCommentPermissions, + adminPerms: mmmodel.SpaceAdminRolePermissions, + guestPerms: []*mmmodel.Permission{mmmodel.PermissionReadPage}, + }, + mmmodel.SchemeNameSpaceReadOnly: { + schemeID: "readonlyscheme000000000000", + userRole: "space_readonly_user_role", + adminRole: "space_readonly_admin_role", + guestRole: "space_readonly_guest_role", + userPerms: mmmodel.SpaceDefaultReadOnlyPermissions, + adminPerms: mmmodel.SpaceAdminRolePermissions, + guestPerms: []*mmmodel.Permission{mmmodel.PermissionReadPage}, + }, +} + +// schemeRoleNames is the generated guest/user/admin role names one scheme id resolves to. +type schemeRoleNames struct { + Guest string + User string + Admin string +} + +// schemeRoleRegistry maps a scheme id to its generated role names, so a stubbed channel resolves +// whichever scheme it currently points at. It is keyed by globally unique scheme ids — the three +// fixed preset ids plus whatever a test registers for a custom scheme — so entries never collide +// across tests. Guarded because a test binary may run packages in parallel. +var schemeRoleRegistry = struct { + sync.RWMutex + byScheme map[string]schemeRoleNames +}{byScheme: presetSchemeRoleNames()} + +func presetSchemeRoleNames() map[string]schemeRoleNames { + out := make(map[string]schemeRoleNames, len(presetSchemeFixtures)) + for _, fx := range presetSchemeFixtures { + out[fx.schemeID] = schemeRoleNames{Guest: fx.guestRole, User: fx.userRole, Admin: fx.adminRole} + } + return out +} + +// RegisterSchemeRoles records the generated role names schemeID resolves to, so a channel later +// repointed at it resolves that scheme's roles through GetSchemeRolesForChannel. The three presets +// are registered already; a test creating a custom scheme registers its own. +func RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole string) { + schemeRoleRegistry.Lock() + defer schemeRoleRegistry.Unlock() + schemeRoleRegistry.byScheme[schemeID] = schemeRoleNames{Guest: guestRole, User: userRole, Admin: adminRole} +} + +func rolesForScheme(schemeID string) (schemeRoleNames, bool) { + schemeRoleRegistry.RLock() + defer schemeRoleRegistry.RUnlock() + roles, ok := schemeRoleRegistry.byScheme[schemeID] + return roles, ok +} + +// PresetSchemeID returns the fixed scheme id StubPresetSchemes stubbed for the named preset (see +// mmmodel.SpaceSchemeNames) — exported for callers that need to seed a *model.Channel pointing at a +// specific preset scheme directly, rather than through MustSeedChannelScheme. +func PresetSchemeID(name string) string { + return presetSchemeFixtures[name].schemeID +} + +// StubPresetSchemes registers .Maybe() stubs on mockAPI resolving each of the three preset space +// schemes by name, plus their three generated roles by name — the shape +// getSchemeIDByName/getRolePermissionsByName resolve through the plugin API in production. Call +// this once per mockAPI, alongside StubDefaultSpacePermissions. +func StubPresetSchemes(mockAPI *plugintest.API) { + for name, fx := range presetSchemeFixtures { + scheme := &mmmodel.Scheme{ + Id: fx.schemeID, + Name: name, + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: fx.userRole, + DefaultChannelAdminRole: fx.adminRole, + DefaultChannelGuestRole: fx.guestRole, + } + mockAPI.On("GetSchemeByName", name).Return(scheme, nil).Maybe() + StubRole(mockAPI, fx.userRole, mmmodel.PermissionIDs(fx.userPerms)) + StubRole(mockAPI, fx.adminRole, mmmodel.PermissionIDs(fx.adminPerms)) + StubRole(mockAPI, fx.guestRole, mmmodel.PermissionIDs(fx.guestPerms)) + } +} + +// StubRole registers a GetRoleByName stub returning one shared *Role carrying permissions, so a +// PatchRole stub that mutates it (see StubPatchRole) is visible to a later read of the same role — +// the mock does not track state on its own. Returns the shared Role. +func StubRole(mockAPI *plugintest.API, roleName string, permissions []string) *mmmodel.Role { + role := &mmmodel.Role{Name: roleName, Permissions: permissions} + mockAPI.On("GetRoleByName", roleName).Return(role, nil).Maybe() + return role +} + +// StubPatchRole registers a PatchRole stub applying the patch's permission set to the shared *Role +// the matching GetRoleByName stub returns, so the write is observable to a later read. +func StubPatchRole(mockAPI *plugintest.API) { + mockAPI.On("PatchRole", mock.AnythingOfType("*model.Role"), mock.AnythingOfType("*model.RolePatch")). + Return(func(role *mmmodel.Role, patch *mmmodel.RolePatch) (*mmmodel.Role, *mmmodel.AppError) { + if patch != nil && patch.Permissions != nil { + role.Permissions = *patch.Permissions + } + return role, nil + }).Maybe() +} + +// MustSeedChannelScheme registers mock stubs so channelID resolves to the named seeded space scheme +// preset: GetChannelOfType returns a channel pointing at the preset's id, and +// GetSchemeRolesForChannel resolves whichever scheme that channel currently points at — so a test +// that repoints the channel sees the new scheme's roles without re-stubbing, the way core resolves +// them. Production sets this up through the real backing channel's SchemeId when +// CreateSpace/SetSpaceDefaultCapabilities points a space at a scheme. +// +// Every matching call returns the same shared *Channel, since a space's scheme-resolving paths may +// call GetChannelOfType more than once per test; a test that also needs to simulate the channel's +// archived/live state (e.g. RestoreSpace's backingChannelArchived check) mutates the returned +// Channel's DeleteAt directly rather than registering a second, competing stub. Marked .Maybe() so +// a test whose flow never reaches a scheme lookup does not fail AssertExpectations on an unused +// stub. Returns the shared Channel. +func MustSeedChannelScheme(t *testing.T, mockAPI *plugintest.API, channelID, schemeName string) *mmmodel.Channel { + t.Helper() + require.NotNil(t, mockAPI, "MustSeedChannelScheme needs a mock to register the channel's scheme stubs on") + fx, ok := presetSchemeFixtures[schemeName] + require.True(t, ok, "unknown preset space scheme name %q", schemeName) + + schemeID := fx.schemeID + channel := &mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace, SchemeId: &schemeID} + StubChannelScheme(mockAPI, channelID, channel) + return channel +} + +// StubChannelScheme wires the three calls that resolve one channel's scheme: the channel read, the +// scheme-role resolution that follows the channel's current SchemeId, and the metadata-sync write. +// +// UpdateChannel must return the SAME shared object GetChannelOfType hands out, because pluginapi's +// Channel.Update copies the returned channel back over the passed one — returning a fresh channel +// would wipe SchemeId and 404 the next scheme-resolving read. +func StubChannelScheme(mockAPI *plugintest.API, channelID string, channel *mmmodel.Channel) { + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace).Return(channel, nil).Maybe() + mockAPI.On("UpdateChannel", channel).Return(channel, nil).Maybe() + mockAPI.On("GetSchemeRolesForChannel", channelID). + Return(func(string) (string, string, string, *mmmodel.AppError) { + return resolveChannelRoles(channel) + }).Maybe() +} + +// resolveChannelRoles mirrors core's GetSchemeRolesForChannel for a stubbed channel: the roles of +// whichever scheme the channel currently points at. A channel with no scheme, or one pointing at a +// scheme no test registered, resolves to empty role names rather than a plausible-looking default, +// so a test relying on an unregistered scheme fails visibly. +func resolveChannelRoles(channel *mmmodel.Channel) (string, string, string, *mmmodel.AppError) { + if channel == nil || channel.SchemeId == nil || *channel.SchemeId == "" { + return "", "", "", nil + } + roles, ok := rolesForScheme(*channel.SchemeId) + if !ok { + return "", "", "", nil + } + return roles.Guest, roles.User, roles.Admin, nil +} + +// StubDefaultChannelScheme registers .Maybe() catch-all stubs resolving any channel not given its +// own specific stub (MustSeedChannelScheme, or a test's own registration) to the contribute preset +// scheme, live (DeleteAt 0) — the same default CreateSpace/MustCreateSpaceWithScheme use for a +// space's backing channel. Each channel id gets its OWN channel object carrying that id, so two +// spaces in one test never alias each other's scheme or metadata. +// +// Register this last: mock.Mock matches expectations in registration order, so a channel-specific +// stub must be registered before this catch-all to take precedence. +func StubDefaultChannelScheme(mockAPI *plugintest.API) { + var mu sync.Mutex + byID := map[string]*mmmodel.Channel{} + defaultChannel := func(channelID string) *mmmodel.Channel { + mu.Lock() + defer mu.Unlock() + if ch, ok := byID[channelID]; ok { + return ch + } + schemeID := PresetSchemeID(mmmodel.SchemeNameSpaceContribute) + ch := &mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace, SchemeId: &schemeID} + byID[channelID] = ch + return ch + } + + mockAPI.On("GetChannelOfType", mock.AnythingOfType("string"), mock.Anything). + Return(func(channelID string, _ mmmodel.ChannelType) (*mmmodel.Channel, *mmmodel.AppError) { + return defaultChannel(channelID), nil + }).Maybe() + mockAPI.On("GetSchemeRolesForChannel", mock.AnythingOfType("string")). + Return(func(channelID string) (string, string, string, *mmmodel.AppError) { + return resolveChannelRoles(defaultChannel(channelID)) + }).Maybe() + // A backing-channel metadata sync (any UpdateSpace that writes the channel, e.g. a view_access + // flip) calls Channel.Update, and pluginapi's Update copies the returned channel back over the + // passed one. Return the same object that was passed so the copy-back is a no-op and the + // channel's SchemeId survives the sync. + mockAPI.On("UpdateChannel", mock.AnythingOfType("*model.Channel")). + Return(func(channel *mmmodel.Channel) (*mmmodel.Channel, *mmmodel.AppError) { + return channel, nil + }).Maybe() +} diff --git a/server/store/scheme_store.go b/server/store/scheme_store.go deleted file mode 100644 index f6d1ea7..0000000 --- a/server/store/scheme_store.go +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package store - -import ( - "database/sql" - "strings" - - "github.com/jmoiron/sqlx" - mmmodel "github.com/mattermost/mattermost/server/public/model" - sq "github.com/mattermost/squirrel" - "github.com/pkg/errors" -) - -// spaceCustomSchemeDisplayName is the DisplayName of every space-private custom scheme this store -// creates: one immutable scheme per non-preset default-capability set. -const spaceCustomSchemeDisplayName = "Space Custom Scheme" - -// spaceCustomSchemeNamePrefix namespaces the Name of every space-private custom scheme this store -// creates. Core reserves only the three seeded preset names (mmmodel.IsSpaceSchemeName), so this -// namespace belongs to the plugin: it is what DeleteSpaceCustomSchemeIfUnreferenced matches on to -// prove a scheme is one this store created before deleting it. -const spaceCustomSchemeNamePrefix = "docs_space_custom_" - -// The generated roles' DisplayName prefixes, mirroring the values core's own scheme store writes -// (its SchemeRoleDisplayNameChannel* constants) so a space-private scheme is indistinguishable -// from a core-created one in the admin console. They are restated here rather than imported: -// those constants live in server/v8, which this module pins as a test-harness-only dependency -// contributing no runtime symbols. -const ( - schemeRoleDisplayNameChannelUser = "Channel User Role for Scheme" - schemeRoleDisplayNameChannelAdmin = "Channel Admin Role for Scheme" - schemeRoleDisplayNameChannelGuest = "Channel Guest Role for Scheme" -) - -// SchemeRoles is the generated channel-scheme role names governing one backing channel's scheme. -// Space capability grants must reference these generated names, not the literal -// channel_user/channel_admin roles: on a scheme-backed channel, core rejects the literal. -type SchemeRoles struct { - SchemeId string `db:"scheme_id"` - SchemeName string `db:"scheme_name"` - UserRoleName string `db:"user_role_name"` - AdminRoleName string `db:"admin_role_name"` - GuestRoleName string `db:"guest_role_name"` -} - -// GetSchemeRolesForChannel returns the generated scheme role names governing channelID's channel -// scheme. Returns ErrNotFound when the channel does not exist or carries no scheme. There is -// deliberately no DeleteAt filter on the channel: a soft-deleted space is restorable and keeps its -// SchemeId. -func (s *Store) GetSchemeRolesForChannel(channelID string) (*SchemeRoles, error) { - if channelID == "" { - return nil, &ErrInvalidInput{Entity: "Channel", Field: "id", Value: channelID} - } - - query := s.getQueryBuilder(). - Select( - "s.Id AS scheme_id", - "s.Name AS scheme_name", - "s.DefaultChannelUserRole AS user_role_name", - "s.DefaultChannelAdminRole AS admin_role_name", - "s.DefaultChannelGuestRole AS guest_role_name", - ). - From("Channels c"). - Join("Schemes s ON s.Id = c.SchemeId"). - Where(sq.Eq{"c.Id": channelID}) - - var roles SchemeRoles - if err := s.getBuilder(s.db, &roles, query); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, &ErrNotFound{EntityName: "ChannelScheme", ID: channelID} - } - return nil, errors.Wrap(err, "unable_to_get_scheme_roles_for_channel") - } - return &roles, nil -} - -// GetSchemeIDByName returns the id of the scheme with the given name, or ErrNotFound if none -// exists. -func (s *Store) GetSchemeIDByName(name string) (string, error) { - if name == "" { - return "", &ErrInvalidInput{Entity: "Scheme", Field: "name", Value: name} - } - - query := s.getQueryBuilder(). - Select("Id"). - From("Schemes"). - Where(sq.Eq{"Name": name}) - - var id string - if err := s.getBuilder(s.db, &id, query); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", &ErrNotFound{EntityName: "Scheme", ID: name} - } - return "", errors.Wrap(err, "unable_to_get_scheme_by_name") - } - return id, nil -} - -// GetRolePermissionsByName returns the permission ids granted by the named role. -// Roles.Permissions is stored as a space-joined string column, not an array. -func (s *Store) GetRolePermissionsByName(roleName string) ([]string, error) { - if roleName == "" { - return nil, &ErrInvalidInput{Entity: "Role", Field: "name", Value: roleName} - } - - query := s.getQueryBuilder(). - Select("Permissions"). - From("Roles"). - Where(sq.Eq{"Name": roleName}) - - var permissions string - if err := s.getBuilder(s.db, &permissions, query); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, &ErrNotFound{EntityName: "Role", ID: roleName} - } - return nil, errors.Wrap(err, "unable_to_get_role_permissions") - } - return strings.Fields(permissions), nil -} - -// CreateSpaceCustomScheme creates one immutable space-private channel scheme with three generated -// roles carrying exactly the given permission sets (user/admin/guest), and returns the new -// scheme's id. The scheme is unreferenced by any channel until the caller repoints the space's -// backing channel at it. -func (s *Store) CreateSpaceCustomScheme(userPermissions, adminPermissions, guestPermissions []string) (_ string, err error) { - tx, cancel, err := s.beginBoundedTx() - if err != nil { - return "", errors.Wrap(err, "begin_transaction") - } - defer cancel() - defer s.finalizeTransaction(tx, &err) - - schemeID := mmmodel.NewId() - schemeName := spaceCustomSchemeNamePrefix + mmmodel.NewId() - now := mmmodel.GetMillis() - - userRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, schemeRoleDisplayNameChannelUser, userPermissions, now) - if roleErr != nil { - return "", roleErr - } - adminRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, schemeRoleDisplayNameChannelAdmin, adminPermissions, now) - if roleErr != nil { - return "", roleErr - } - guestRoleName, roleErr := s.createSchemeRole(tx, schemeID, schemeName, schemeRoleDisplayNameChannelGuest, guestPermissions, now) - if roleErr != nil { - return "", roleErr - } - - schemeBuilder := s.getQueryBuilder(). - Insert("Schemes"). - Columns( - "Id", "Name", "DisplayName", "Description", "Scope", - "DefaultTeamAdminRole", "DefaultTeamUserRole", "DefaultTeamGuestRole", - "DefaultChannelAdminRole", "DefaultChannelUserRole", "DefaultChannelGuestRole", - "CreateAt", "UpdateAt", "DeleteAt", - "DefaultPlaybookAdminRole", "DefaultPlaybookMemberRole", "DefaultRunAdminRole", "DefaultRunMemberRole", - ). - Values( - schemeID, schemeName, spaceCustomSchemeDisplayName, "", mmmodel.SchemeScopeChannel, - "", "", "", - adminRoleName, userRoleName, guestRoleName, - now, now, 0, - "", "", "", "", - ) - if _, execErr := s.execBuilder(tx, schemeBuilder); execErr != nil { - return "", errors.Wrap(execErr, "unable_to_save_space_custom_scheme") - } - - if err = tx.Commit(); err != nil { - return "", errors.Wrap(err, "commit_transaction") - } - return schemeID, nil -} - -// createSchemeRole inserts one generated, exact-permission, SchemeManaged role belonging to a -// space-private custom scheme, mirroring core's createScheme role shape: SchemeManaged true (the -// discriminator UpdateChannelMemberRoles requires to accept the generated name on the wire), -// BuiltIn false, SchemeId set. Must run inside tx. -func (s *Store) createSchemeRole(tx *sqlx.Tx, schemeID, schemeName, displayNamePrefix string, permissions []string, now int64) (string, error) { - roleName := mmmodel.NewId() - builder := s.getQueryBuilder(). - Insert("Roles"). - Columns("Id", "Name", "DisplayName", "Description", "Permissions", "CreateAt", "UpdateAt", "DeleteAt", "SchemeManaged", "BuiltIn", "SchemeId"). - Values(mmmodel.NewId(), roleName, displayNamePrefix+" "+schemeName, "", joinRolePermissions(permissions), now, now, 0, true, false, schemeID) - if _, err := s.execBuilder(tx, builder); err != nil { - return "", errors.Wrap(err, "unable_to_save_space_custom_scheme_role") - } - return roleName, nil -} - -// joinRolePermissions renders permissions as core's Roles.Permissions column shape: a leading- -// space-joined string (NewRoleFromModel's convention), read back with strings.Fields. -func joinRolePermissions(permissions []string) string { - return " " + strings.Join(permissions, " ") -} - -// DeleteSpaceCustomSchemeIfUnreferenced deletes a space-private custom scheme and its three -// generated roles once no channel — regardless of DeleteAt, since a soft-deleted space is -// restorable and keeps its SchemeId — still references it. excludeChannelID, when non-empty, is -// omitted from that reference count: space creation archives its backing channel when a later -// step fails, and an archived channel keeps its SchemeId, so without the exclusion the abandoned -// channel would count as a live reference and the scheme could never be retired. A no-op (not an -// error) when the scheme is still referenced. Returns ErrInvalidInput for a scheme this store did -// not create. -func (s *Store) DeleteSpaceCustomSchemeIfUnreferenced(schemeID, excludeChannelID string) (err error) { - if schemeID == "" { - return &ErrInvalidInput{Entity: "Scheme", Field: "id", Value: schemeID} - } - - tx, cancel, err := s.beginBoundedTx() - if err != nil { - return errors.Wrap(err, "begin_transaction") - } - defer cancel() - defer s.finalizeTransaction(tx, &err) - - var scheme struct { - Name string - DefaultChannelUserRole string - DefaultChannelAdminRole string - DefaultChannelGuestRole string - } - schemeQuery := s.getQueryBuilder(). - Select("Name", "DefaultChannelUserRole", "DefaultChannelAdminRole", "DefaultChannelGuestRole"). - From("Schemes"). - Where(sq.Eq{"Id": schemeID}) - if txErr := s.getBuilder(tx, &scheme, schemeQuery); txErr != nil { - if errors.Is(txErr, sql.ErrNoRows) { - return &ErrNotFound{EntityName: "Scheme", ID: schemeID} - } - return errors.Wrap(txErr, "failed to read scheme for retirement") - } - // Two independent conditions must hold before anything is deleted: the name must carry this - // store's own namespace with a non-empty suffix (proving this store created it), and it must - // not be one of core's seeded preset names. - if len(scheme.Name) <= len(spaceCustomSchemeNamePrefix) || - !strings.HasPrefix(scheme.Name, spaceCustomSchemeNamePrefix) || - mmmodel.IsSpaceSchemeName(scheme.Name) { - return &ErrInvalidInput{Entity: "Scheme", Field: "name", Value: scheme.Name} - } - - var referenced int - refQuery := s.getQueryBuilder().Select("COUNT(*)").From("Channels").Where(sq.Eq{"SchemeId": schemeID}) - if excludeChannelID != "" { - refQuery = refQuery.Where(sq.NotEq{"Id": excludeChannelID}) - } - if txErr := s.getBuilder(tx, &referenced, refQuery); txErr != nil { - return errors.Wrap(txErr, "failed to count channels referencing scheme") - } - if referenced > 0 { - return nil - } - - roleNames := []string{scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole, scheme.DefaultChannelGuestRole} - deleteRoles := s.getQueryBuilder().Delete("Roles").Where(sq.Eq{"Name": roleNames}) - if _, txErr := s.execBuilder(tx, deleteRoles); txErr != nil { - return errors.Wrap(txErr, "failed to delete space custom scheme roles") - } - - deleteScheme := s.getQueryBuilder().Delete("Schemes").Where(sq.Eq{"Id": schemeID}) - if _, txErr := s.execBuilder(tx, deleteScheme); txErr != nil { - return errors.Wrap(txErr, "failed to delete space custom scheme") - } - - if err = tx.Commit(); err != nil { - return errors.Wrap(err, "commit_transaction") - } - return nil -} diff --git a/server/store/scheme_store_test.go b/server/store/scheme_store_test.go deleted file mode 100644 index e3992a9..0000000 --- a/server/store/scheme_store_test.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package store_test - -import ( - "testing" - - mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" - "github.com/mattermost/mattermost-plugin-docs/server/store" -) - -// TestSpaceCustomScheme_RoundTrip verifies CreateSpaceCustomScheme -> GetSchemeRolesForChannel -> -// GetRolePermissionsByName: a custom scheme's generated user-role permissions are readable back -// exactly once a channel is repointed at it (mirroring SetSpaceDefaultCapabilities' repoint). -func TestSpaceCustomScheme_RoundTrip(t *testing.T) { - s, db := testutil.OpenTestStore(t) - - userPerms := []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionCommentPage.Id} - adminPerms := []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id} - guestPerms := []string{mmmodel.PermissionReadPage.Id} - - schemeID, err := s.CreateSpaceCustomScheme(userPerms, adminPerms, guestPerms) - require.NoError(t, err) - require.NotEmpty(t, schemeID) - - channelID := mmmodel.NewId() - _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) - require.NoError(t, err) - - roles, err := s.GetSchemeRolesForChannel(channelID) - require.NoError(t, err) - require.Equal(t, schemeID, roles.SchemeId) - require.NotEmpty(t, roles.UserRoleName) - require.NotEmpty(t, roles.AdminRoleName) - require.NotEmpty(t, roles.GuestRoleName) - - userRolePerms, err := s.GetRolePermissionsByName(roles.UserRoleName) - require.NoError(t, err) - require.ElementsMatch(t, userPerms, userRolePerms) - - adminRolePerms, err := s.GetRolePermissionsByName(roles.AdminRoleName) - require.NoError(t, err) - require.ElementsMatch(t, adminPerms, adminRolePerms) - - guestRolePerms, err := s.GetRolePermissionsByName(roles.GuestRoleName) - require.NoError(t, err) - require.ElementsMatch(t, guestPerms, guestRolePerms) -} - -// TestDeleteSpaceCustomSchemeIfUnreferenced covers the three outcomes: a no-op while a channel -// still references the scheme, an actual delete once unreferenced, and a rejection when the -// scheme id names one of the three seeded presets rather than a space-private custom scheme. -func TestDeleteSpaceCustomSchemeIfUnreferenced(t *testing.T) { - s, db := testutil.OpenTestStore(t) - - t.Run("no-op while referenced", func(t *testing.T) { - schemeID, err := s.CreateSpaceCustomScheme( - []string{mmmodel.PermissionReadPage.Id}, - []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, - []string{mmmodel.PermissionReadPage.Id}, - ) - require.NoError(t, err) - - channelID := mmmodel.NewId() - _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, "")) - - // Still readable: the delete was a no-op. - roles, err := s.GetSchemeRolesForChannel(channelID) - require.NoError(t, err) - require.Equal(t, schemeID, roles.SchemeId) - }) - - t.Run("deletes when the only reference is the excluded channel", func(t *testing.T) { - schemeID, err := s.CreateSpaceCustomScheme( - []string{mmmodel.PermissionReadPage.Id}, - []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, - []string{mmmodel.PermissionReadPage.Id}, - ) - require.NoError(t, err) - - // Stands in for a channel abandoned by a failed space-creation step (see - // DeleteSpaceCustomSchemeIfUnreferenced's excludeChannelID doc). - channelID := mmmodel.NewId() - _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2)`, channelID, schemeID) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, channelID)) - - var count int - require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, schemeID).Scan(&count)) - require.Zero(t, count, "the abandoned backing channel must not count as a live reference") - }) - - t.Run("still a no-op when another channel references the scheme", func(t *testing.T) { - schemeID, err := s.CreateSpaceCustomScheme( - []string{mmmodel.PermissionReadPage.Id}, - []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, - []string{mmmodel.PermissionReadPage.Id}, - ) - require.NoError(t, err) - - abandonedID, otherID := mmmodel.NewId(), mmmodel.NewId() - _, err = db.Exec(`INSERT INTO Channels (Id, SchemeId) VALUES ($1, $2), ($3, $2)`, abandonedID, schemeID, otherID) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, abandonedID)) - - var count int - require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, schemeID).Scan(&count)) - require.Equal(t, 1, count, "excluding one channel must not delete a scheme another channel still uses") - }) - - t.Run("deletes when unreferenced", func(t *testing.T) { - schemeID, err := s.CreateSpaceCustomScheme( - []string{mmmodel.PermissionReadPage.Id}, - []string{mmmodel.PermissionReadPage.Id, mmmodel.PermissionAdminSpace.Id}, - []string{mmmodel.PermissionReadPage.Id}, - ) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpaceCustomSchemeIfUnreferenced(schemeID, "")) - - var count int - require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, schemeID).Scan(&count)) - require.Zero(t, count, "the scheme row must be gone once unreferenced") - }) - - t.Run("rejects a preset scheme id", func(t *testing.T) { - var presetID string - require.NoError(t, db.QueryRow(`SELECT Id FROM Schemes WHERE Name = $1`, mmmodel.SchemeNameSpaceContribute).Scan(&presetID)) - - err := s.DeleteSpaceCustomSchemeIfUnreferenced(presetID, "") - require.Error(t, err) - require.True(t, store.IsErrInvalidInput(err)) - - var count int - require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM Schemes WHERE Id = $1`, presetID).Scan(&count)) - require.Equal(t, 1, count, "a preset scheme must never be deleted") - }) -} From 4ba808c09b1b264f33e39e118b34ac79d0e1ef79 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 4 Aug 2026 08:57:26 +0200 Subject: [PATCH 20/28] further changes --- server/api_handler_test.go | 88 +++++----- server/api_space.go | 12 +- server/app/page_hierarchy.go | 2 +- server/app/page_move_to_space_test.go | 5 +- server/app/permissions.go | 15 +- server/app/permissions_test.go | 221 +++++++++++++++++++++++++ server/app/scheme.go | 135 ++++++--------- server/app/service.go | 7 +- server/app/space.go | 124 +++++--------- server/app/space_members.go | 43 +++-- server/app/space_test.go | 120 ++++++-------- server/app/ws_events.go | 6 +- server/app/ws_events_test.go | 48 ++++++ server/e2e/scenarios_test.go | 5 +- server/internal/testutil/schemestub.go | 137 ++++++++++++++- server/model/page_content.go | 2 +- server/model/space.go | 49 ++++-- server/model/space_capabilities.go | 47 +++++- server/store/page_hierarchy.go | 2 +- server/store/store.go | 16 +- 20 files changed, 750 insertions(+), 334 deletions(-) create mode 100644 server/app/permissions_test.go diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 1a8023d..23dccf0 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -205,10 +205,17 @@ func TestHandler_CreateSpace(t *testing.T) { }) require.Equal(t, http.StatusCreated, rec.Code) - var created model.Space + var created model.SpaceWithAccess require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) require.NotEmpty(t, created.Id) require.Equal(t, "My Space", created.Title) + // Create establishes the space's access state, so the response carries it and the caller needs + // no follow-up read: the seeded contribute default, and the creator's own admin set. + contribute, ok := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + require.True(t, ok) + require.Equal(t, contribute, created.DefaultCapabilities) + require.Equal(t, model.AdminEffectiveCapabilities(), created.Capabilities) + require.Contains(t, created.Capabilities, model.CapabilityAdminSpace) } // TestHandler_CreateSpace_IgnoresServerOwnedFields ensures the create handler does not trust @@ -2066,12 +2073,6 @@ func stubSpaceSchemeRepoint(t *testing.T, mockAPI *plugintest.API, channelID str return testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) } -// spaceCustomSchemeNamePrefix mirrors the plugin-internal prefix app.createSpaceCustomScheme labels -// its schemes with. It is restated here rather than imported because it is unexported, and it is -// only a label: core proves a scheme's space scope by the backing channel pointing at it, never by -// the name. -const spaceCustomSchemeNamePrefix = "docs_space_custom_" - // stubSpaceCustomSchemeCreate wires the mock calls the custom-scheme path needs for a non-preset // default-capability set: CreateScheme returning a scheme whose Name carries the custom prefix, // plus GetRoleByName and PatchRole for each of its three generated roles. The new scheme's role @@ -2082,11 +2083,12 @@ const spaceCustomSchemeNamePrefix = "docs_space_custom_" // channel's post-repoint SchemeId, or a later DeleteScheme call retiring it). func stubSpaceCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { t.Helper() + testutil.StubPooledSchemeMiss(mockAPI) customSchemeID := mmmodel.NewId() userRole, adminRole, guestRole := "custom_scheme_user_role", "custom_scheme_admin_role", "custom_scheme_guest_role" customScheme := &mmmodel.Scheme{ Id: customSchemeID, - Name: spaceCustomSchemeNamePrefix + mmmodel.NewId(), + Name: model.SharedSchemeNameForCapabilities([]string{"create_page"}), Scope: mmmodel.SchemeScopeChannel, DefaultChannelUserRole: userRole, DefaultChannelAdminRole: adminRole, @@ -2116,7 +2118,10 @@ func assertCustomSchemeRolePermissions(t *testing.T, mockAPI *plugintest.API, ca } for roleName, perms := range expected { mockAPI.AssertCalled(t, "PatchRole", - mock.MatchedBy(func(r *mmmodel.Role) bool { return r.Name == roleName }), + mock.MatchedBy(func(roleID string) bool { + patchedName, ok := testutil.StubbedRoleName(roleID) + return ok && patchedName == roleName + }), mock.MatchedBy(func(p *mmmodel.RolePatch) bool { return p != nil && p.Permissions != nil && slices.Equal( model.NormalizeCapabilitySet(*p.Permissions), model.NormalizeCapabilitySet(perms)) @@ -2208,10 +2213,11 @@ func TestHandler_SetSpaceDefaultCapabilities_InvalidCapability(t *testing.T) { } } -// TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme verifies a non-preset default -// capability set repoints the backing channel at a newly created space-private custom scheme -// (named with the docs_space_custom_ prefix), and the response echoes exactly the requested set. -func TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme(t *testing.T) { +// TestHandler_SetSpaceDefaultCapabilities_CreatesPooledScheme verifies a non-preset default +// capability set repoints the backing channel at a scheme from the shared pool, minted on first +// use under the name that capability set resolves to, and that the response echoes exactly the +// requested set. +func TestHandler_SetSpaceDefaultCapabilities_CreatesPooledScheme(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() @@ -2231,11 +2237,13 @@ func TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) require.Equal(t, []string{"create_page"}, updated.DefaultCapabilities) + // The pool key is a pure function of the capability set, so the minted scheme's name is exactly + // the one any other space requesting this set would resolve to. mockAPI.AssertCalled(t, "CreateScheme", mock.MatchedBy(func(s *mmmodel.Scheme) bool { - return strings.HasPrefix(s.Name, spaceCustomSchemeNamePrefix) + return s.Name == model.SharedSchemeNameForCapabilities([]string{"create_page"}) })) require.NotNil(t, channel.SchemeId) - require.Equal(t, customSchemeID, *channel.SchemeId, "the channel must be repointed at the newly created custom scheme") + require.Equal(t, customSchemeID, *channel.SchemeId, "the channel must be repointed at the pooled scheme") assertCustomSchemeRolePermissions(t, mockAPI, []string{"create_page"}) // Exactly the three generated roles (user/admin/guest) are patched — no role skipped, none // double-patched. assertCustomSchemeRolePermissions pins the per-role content; this pins the count. @@ -2243,42 +2251,48 @@ func TestHandler_SetSpaceDefaultCapabilities_CreatesCustomScheme(t *testing.T) { } // TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomScheme verifies that -// switching a space back to a preset default capability set repoints the channel at the shared -// preset scheme and retires the previously created custom scheme (and its roles) with no orphans -// left behind. -func TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomScheme(t *testing.T) { +// TestHandler_SetSpaceDefaultCapabilities_ReusesPooledScheme pins the property the shared pool +// exists for: a capability set resolves to one scheme forever. Switching a space to a non-preset +// set mints a pooled scheme, switching away to a preset leaves it in place, and switching back +// resolves to that same scheme rather than minting a second one carrying identical permissions. +func TestHandler_SetSpaceDefaultCapabilities_ReusesPooledScheme(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() mockAPI := newEnabledMockAPI() grantSpaceAdmin(mockAPI, channelID, userID) channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) - customSchemeID := stubSpaceCustomSchemeCreate(t, mockAPI) - mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, nil) + testutil.StubSchemePool(mockAPI) h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ - "default_capabilities": []string{"create_page"}, - }) - require.Equal(t, http.StatusOK, rec.Code) - require.NotNil(t, channel.SchemeId) - require.Equal(t, customSchemeID, *channel.SchemeId) + setDefaults := func(capabilities []string) { + t.Helper() + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": capabilities, + }) + require.Equal(t, http.StatusOK, rec.Code) + } - rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ - "default_capabilities": []string{"comment_page", "create_page", "edit_page", "delete_own_page"}, - }) - require.Equal(t, http.StatusOK, rec.Code) + setDefaults([]string{"create_page"}) + require.NotNil(t, channel.SchemeId) + pooledSchemeID := *channel.SchemeId + require.NotEqual(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), pooledSchemeID) + setDefaults([]string{"comment_page", "create_page", "edit_page", "delete_own_page"}) require.NotNil(t, channel.SchemeId) require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, - "the channel must be repointed at the shared contribute preset scheme") - mockAPI.AssertCalled(t, "DeleteScheme", customSchemeID) - // The custom scheme is minted once (first PATCH) and retired once (switch back). A regression - // that re-minted a scheme on the preset switch, or retired more than the one superseded scheme, - // would change these counts while still passing the AssertCalled checks above. + "a set matching a preset must repoint at the shared preset scheme") + + setDefaults([]string{"create_page"}) + require.NotNil(t, channel.SchemeId) + require.Equal(t, pooledSchemeID, *channel.SchemeId, + "returning to a capability set must resolve to the scheme already pooled for it") + + // One scheme minted across all three switches, and none deleted: a pooled scheme is shared, so + // no space owns it and no repoint retires it. mockAPI.AssertNumberOfCalls(t, "CreateScheme", 1) - mockAPI.AssertNumberOfCalls(t, "DeleteScheme", 1) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) } // TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp verifies that resubmitting the diff --git a/server/api_space.go b/server/api_space.go index ec61340..77f90ff 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -38,11 +38,11 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { teamID := mux.Vars(r)["team_id"] var req struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - DefaultCapabilities *[]string `json:"default_capabilities,omitempty"` - ViewAccess *string `json:"view_access,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + DefaultCapabilities *[]string `json:"default_capabilities,omitempty"` + ViewAccess *model.ViewAccess `json:"view_access,omitempty"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleCreateSpace", false) { return @@ -102,7 +102,7 @@ func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { Description *string `json:"description"` Icon *string `json:"icon"` Props *mmmodel.StringInterface `json:"props"` - ViewAccess *string `json:"view_access"` + ViewAccess *model.ViewAccess `json:"view_access"` ExpectedUpdateAt *int64 `json:"expected_update_at"` Force bool `json:"force"` } diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 2ec174a..1e02754 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -227,7 +227,7 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model return nil, pageErr } if page.UserId != requiredOwnerID { - return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusBadRequest) + return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusForbidden) } } if requestedParent != "" && requestedParent != curParentID { diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index 1d6767f..4a482c5 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -5,6 +5,7 @@ package app_test import ( "database/sql" + "net/http" "testing" "github.com/stretchr/testify/mock" @@ -375,7 +376,9 @@ func TestServiceMovePageToSpace_SameSpaceRequiredOwnerID(t *testing.T) { foreign := mustCreatePage(t, h.store, space.Id, ch, mmmodel.NewId(), "") _, appErr := h.svc.MovePageToSpace(foreign.Id, space, space, &parentID, new(foreign.UpdateAt), false, owner, owner) require.NotNil(t, appErr) - require.Equal(t, 400, appErr.StatusCode) + // An authorization denial, so it carries 403 like every other own/any denial in the feature — + // not the 400 a malformed request would get. + require.Equal(t, http.StatusForbidden, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.subtree_not_owned.app_error", appErr.Id) stillRoot, getErr := h.svc.GetPage(foreign.Id) diff --git a/server/app/permissions.go b/server/app/permissions.go index 3a7e280..04a5d24 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -251,9 +251,16 @@ func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID // state other users can see. Checking the looser pair here also keeps autosave off the page-liveness // lookup that the precise choice would require. func (s *Service) RequireSpaceDraftWrite(where string, space *model.Space, userID string, admittedVia ReadResolution) *mmmodel.AppError { - if appErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia); appErr == nil { + createErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia) + if createErr == nil { return nil } + // Only a denial falls through to the second attempt: a failure of the check itself must not be + // retried into a 403, which would present a backend outage to the user as "not authorized". + // Mirrors ResolveSpacePageOwnOrAny's handling of the same two-attempt shape. + if createErr.StatusCode != http.StatusForbidden { + return createErr + } return s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionEditPage, admittedVia) } @@ -361,7 +368,11 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad // Published after the lock is released: the membership lock holds a dedicated connection, so a // slow publish inside it would push concurrent membership mutations into a lock timeout. if joined { - s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": joinedUserID}, joinedChannelID) + payload := map[string]any{"space_id": space.Id, "user_id": joinedUserID} + s.publishToChannels(wsEventSpaceMemberAdded, payload, joinedChannelID) + // Also delivered directly, matching AddSpaceMember: the channel-scoped broadcast may not + // resolve the just-joined member, who has no other signal that the auto-join happened. + s.publishToUser(wsEventSpaceMemberAdded, payload, joinedUserID) } return joined, nil } diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go new file mode 100644 index 0000000..4638baf --- /dev/null +++ b/server/app/permissions_test.go @@ -0,0 +1,221 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app_test + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + + "github.com/mattermost/mattermost-plugin-docs/server/app" + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// stubNonMember denies userID every backing-channel page permission, so the read resolver admits +// them through the open-space team fall-through rather than as a member — the only admission +// auto-join acts on. Must run before the harness constructor: mock.Mock matches expectations in +// registration order, and StubDefaultSpacePermissions' catch-all otherwise grants read_page first. +func stubNonMember(mockAPI *plugintest.API, userID string) { + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadPage, mmmodel.PermissionCreatePage, mmmodel.PermissionCommentPage, + mmmodel.PermissionEditPage, mmmodel.PermissionDeleteOwnPage, + } { + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, p).Return(false).Maybe() + } +} + +// autoJoinHarness seeds one open space whose backing channel resolves to the contribute preset, +// and returns the harness plus that space. The caller stubs whichever of RolesGrantPermission / +// GetChannelMember / AddChannelMember its scenario reaches. +func autoJoinHarness(t *testing.T, mockAPI *plugintest.API, viewAccess model.ViewAccess) (*testHarness, *model.Space) { + t.Helper() + h := openTestServiceWithAPI(t, mockAPI) + channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + space := testutil.MustCreateSpace(t, h.store, channelID, mmmodel.NewId()) + if space.ViewAccess != viewAccess { + updated, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &viewAccess}, space.UpdateAt, false) + require.NoError(t, err) + space = updated + } + return h, space +} + +// TestAutoJoin_NotFallthroughIsNoOp covers the guard that makes auto-join apply to exactly one +// admission path: a caller admitted as a member (or as sysadmin, or denied outright) must never be +// joined, since joining is only ever a consequence of the open-space non-member fall-through. +func TestAutoJoin_NotFallthroughIsNoOp(t *testing.T) { + for _, resolution := range []app.ReadResolution{app.ReadDenied, app.ReadViaSysadmin, app.ReadViaMember} { + mockAPI := &plugintest.API{} + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, mmmodel.NewId(), resolution, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined, "resolution %v must not auto-join", resolution) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) + } +} + +// TestAutoJoin_JoinsWhenDefaultGrants is the successful pre-step: a non-member admitted via the +// open-space fall-through, whose space default grants the permission, is added to the backing +// channel and the membership-added event is published. +func TestAutoJoin_JoinsWhenDefaultGrants(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + // Not yet a member: the join path runs only when the membership probe misses. + mockAPI.On("GetChannelMember", space.ChannelId, userID).Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: 404}) + mockAPI.On("AddChannelMember", space.ChannelId, userID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: userID}, nil) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.True(t, joined) + mockAPI.AssertCalled(t, "AddChannelMember", space.ChannelId, userID) +} + +// TestAutoJoin_DefaultDoesNotGrant covers the admission test: the fall-through alone never joins. +// A space whose default capability set withholds the permission leaves the caller a non-member, so +// the write gate that follows denies them rather than silently granting access by joining first. +func TestAutoJoin_DefaultDoesNotGrant(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(false) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_PrivateFlipAbortsJoin is the concurrency guard: the admitting read happened against +// an open space, but the space is private by the time the pre-step re-reads it under the lock. The +// stale admission must not survive that flip — re-validation, not the caller's resolution, decides. +func TestAutoJoin_PrivateFlipAbortsJoin(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + // The caller still holds the open-space record it was admitted against; the stored row has + // since flipped private, which is what the pre-step re-reads. + stale := *space + private := model.ViewAccessPrivate + _, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &private}, space.UpdateAt, false) + require.NoError(t, err) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(&stale, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined, "a space that flipped private must not auto-join a stale open-read admission") + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_DeletedSpaceIsNoOp covers the space-vanished branch: a space deleted between the +// admitting read and the pre-step yields no join and no error, rather than a 500 on a race the +// caller cannot act on. +func TestAutoJoin_DeletedSpaceIsNoOp(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + require.NoError(t, h.store.DeleteSpace(space.Id)) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_OwnerCheckGatesJoin covers the delete_own_page contract: the ownerCheck must hold +// before the join, so a caller who does not own the target page is never made a member on the +// strength of an own-page permission that cannot apply to it. +func TestAutoJoin_OwnerCheckGatesJoin(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionDeleteOwnPage.Id).Return(true) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, + mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return false, nil }) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_OwnerCheckFailurePropagates distinguishes a failed ownership lookup from a negative +// one: the first is an outage and must surface as an error, not as a silent no-join the caller +// would report to the user as a denial. +func TestAutoJoin_OwnerCheckFailurePropagates(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionDeleteOwnPage.Id).Return(true) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, + mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return false, errors.New("lookup failed") }) + require.NotNil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_AlreadyMemberIsNoOp covers idempotency: an existing member is not re-added, so a +// repeated write by an already-joined caller does not churn membership or republish the event. +func TestAutoJoin_AlreadyMemberIsNoOp(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + mockAPI.On("GetChannelMember", space.ChannelId, userID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: userID}, nil) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestRequireSpaceDraftWrite_LookupFailureIsNotADenial pins the error discipline of the two-attempt +// draft gate: a failure of the create_page check must surface as itself, not be retried into the +// edit_page attempt and reported as a 403 — a backend outage must never read as "not authorized". +func TestRequireSpaceDraftWrite_LookupFailureIsNotADenial(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + // The scheme lookup behind the auto-join pre-step fails outright. That is a failure of the + // check, not a denial, so it must not be swallowed and retried as the edit_page attempt. + channelID := mmmodel.NewId() + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace). + Return((*mmmodel.Channel)(nil), &mmmodel.AppError{StatusCode: 500, Id: "channel.lookup.failed"}) + h := openTestServiceWithAPI(t, mockAPI) + space := testutil.MustCreateSpace(t, h.store, channelID, mmmodel.NewId()) + + appErr := h.svc.RequireSpaceDraftWrite("test", space, userID, app.ReadViaOpenFallthrough) + + require.NotNil(t, appErr) + require.NotEqual(t, http.StatusForbidden, appErr.StatusCode, + "a failed lookup must not be reported as an authorization denial") + // One attempt, not two: the create_page failure short-circuits instead of falling through to + // the edit_page retry. Each attempt runs the pre-step, which resolves team membership once. + mockAPI.AssertNumberOfCalls(t, "GetTeamMember", 1) +} diff --git a/server/app/scheme.go b/server/app/scheme.go index 2e764c1..402e521 100644 --- a/server/app/scheme.go +++ b/server/app/scheme.go @@ -13,15 +13,14 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) -// spaceCustomSchemeDisplayName is the DisplayName of every space-private custom scheme: one -// immutable scheme per non-preset default-capability set. -const spaceCustomSchemeDisplayName = "Space Custom Scheme" - -// spaceCustomSchemeNamePrefix labels the Name of every space-private custom scheme this plugin -// creates. It is an operator-facing label only: core accepts a scheme name as proof of space scope -// solely for the three reserved preset names, and a per-space custom scheme proves its scope by -// having a space backing channel point at it instead. -const spaceCustomSchemeNamePrefix = "docs_space_custom_" +// A non-preset default-capability set resolves to a scheme in a shared pool keyed by that set, so +// the number of schemes is bounded by the capability vocabulary rather than by the number of +// spaces: every space configured the same way points at one scheme. A pooled scheme is never +// deleted — nothing owns it — so there is no retirement, no reference counting, and no residue +// from an interrupted create. +// +// Core accepts a scheme name as proof of space scope only for the three reserved preset names; a +// pooled scheme proves its scope by having a space backing channel point at it instead. // schemeRoles is the generated channel-scheme role names governing one backing channel's scheme. // Space capability grants reference these generated names, not the literal @@ -77,24 +76,6 @@ func (s *Service) schemeRolesFromChannel(channelID string, channel *mmmodel.Chan }, nil } -// isPresetSchemeID reports whether schemeID is one of the three seeded preset schemes, by resolving -// each reserved name to its id. Preset membership is settled by identity rather than by projecting -// the scheme's capability set, because a custom scheme whose generated roles were never given their -// permission sets still carries core's default channel baseline, which projects to the same empty -// set as the read-only preset. -func (s *Service) isPresetSchemeID(schemeID string) (bool, error) { - for _, name := range mmmodel.SpaceSchemeNames { - presetID, err := s.getSchemeIDByName(name) - if err != nil { - return false, err - } - if presetID == schemeID { - return true, nil - } - } - return false, nil -} - // getSchemeIDByName returns the id of the scheme with the given name. func (s *Service) getSchemeIDByName(name string) (string, error) { scheme, err := s.client.Scheme.GetByName(name) @@ -119,36 +100,60 @@ func (s *Service) getRolePermissionsByName(roleName string) ([]string, error) { return role.Permissions, nil } -// createSpaceCustomScheme creates one space-private channel scheme and returns its id along with -// the three roles core generated for it. Those roles start on the default channel baseline; they -// are given their exact permission sets later by configureSpaceCustomScheme, which cannot run until -// a space backing channel points at the scheme. Until the caller attaches it, the scheme is -// referenced by nothing, so a caller whose next step fails retires it directly. -func (s *Service) createSpaceCustomScheme() (string, *schemeRoles, error) { +// getOrCreateSharedScheme resolves the pooled channel scheme expressing capabilities, creating it +// on first use. The name is a pure function of the capability set (see +// model.SharedSchemeNameForCapabilities), so every space configured that way resolves to the same +// scheme: two schemes carrying one capability set would be indistinguishable in behaviour, since +// nothing reads a scheme id or a generated role name for meaning. +// +// The returned roles start on core's default channel baseline when the scheme is new, and the +// caller gives them their permission sets through configureSharedScheme once a backing channel +// points at the scheme. That configure runs on every resolution, not only on creation: a scheme +// created by a racing caller may still be mid-configuration when this one finds it, and rewriting +// an already-correct permission set is idempotent. +func (s *Service) getOrCreateSharedScheme(capabilities []string) (string, *schemeRoles, error) { + name := model.SharedSchemeNameForCapabilities(capabilities) + if scheme, err := s.client.Scheme.GetByName(name); err == nil { + return scheme.Id, rolesFromScheme(scheme), nil + } else if !errors.Is(err, pluginapi.ErrNotFound) { + return "", nil, err + } + scheme, err := s.client.Scheme.Create(&mmmodel.Scheme{ - Name: spaceCustomSchemeNamePrefix + mmmodel.NewId(), - DisplayName: spaceCustomSchemeDisplayName, + Name: name, + DisplayName: model.SharedSchemeDisplayNameForCapabilities(capabilities), Scope: mmmodel.SchemeScopeChannel, }) if err != nil { + // The name is unique, so a concurrent first use of the same capability set loses this + // create and adopts the winner's scheme rather than failing the caller. + if existing, getErr := s.client.Scheme.GetByName(name); getErr == nil { + return existing.Id, rolesFromScheme(existing), nil + } return "", nil, err } - return scheme.Id, &schemeRoles{ + return scheme.Id, rolesFromScheme(scheme), nil +} + +// rolesFromScheme names the three roles core generated for scheme. +func rolesFromScheme(scheme *mmmodel.Scheme) *schemeRoles { + return &schemeRoles{ UserRoleName: scheme.DefaultChannelUserRole, AdminRoleName: scheme.DefaultChannelAdminRole, GuestRoleName: scheme.DefaultChannelGuestRole, - }, nil + } } -// configureSpaceCustomScheme replaces the permission sets of the three roles generated for a -// space-private custom scheme, so the space's members hold exactly capabilities plus the baseline -// read. roles are the names createSpaceCustomScheme returned, so the writes land on the scheme that -// was created rather than on whatever a channel currently resolves to. +// configureSharedScheme writes the permission sets of the three roles generated for a pooled +// scheme, so members of a space pointing at it hold exactly capabilities plus the baseline read. +// roles are the names getOrCreateSharedScheme returned, so the writes land on the resolved scheme +// rather than on whatever a channel currently points at. // // It must run only once a space backing channel already points at that scheme: core admits a role // write carrying space permissions for a seeded preset's roles, or for a scheme a space backing // channel already references, and it does not accept a caller-chosen scheme name as proof. -func (s *Service) configureSpaceCustomScheme(roles *schemeRoles, capabilities []string) error { +// Idempotent, so re-running it against an already-configured pooled scheme is a no-op in effect. +func (s *Service) configureSharedScheme(roles *schemeRoles, capabilities []string) error { capabilities = model.NormalizeCapabilitySet(capabilities) roleSets := []struct { name string @@ -177,7 +182,9 @@ func (s *Service) setRolePermissions(roleName string, permissions []string) erro } return err } - if _, err = s.client.Role.Patch(role, &mmmodel.RolePatch{Permissions: &permissions}); err != nil { + // Patched by id rather than by handing back the role just read: core re-reads the stored role + // so its scope guard judges a SchemeId the caller cannot influence. + if _, err = s.client.Role.Patch(role.Id, &mmmodel.RolePatch{Permissions: &permissions}); err != nil { if errors.Is(err, pluginapi.ErrNotFound) { return &store.ErrNotFound{EntityName: "Role", ID: roleName} } @@ -185,45 +192,3 @@ func (s *Service) setRolePermissions(roleName string, permissions []string) erro } return nil } - -// retireSpaceCustomScheme deletes a space-private custom scheme once its space no longer uses it. -// Core refuses to delete a seeded preset, and refuses any scheme a space backing channel still -// references, so a caller must have repointed the channel to another scheme first; the abandon path -// uses detachAndDeleteCustomScheme instead. A scheme already gone is a no-op. -func (s *Service) retireSpaceCustomScheme(schemeID string) error { - if _, err := s.client.Scheme.Delete(schemeID); err != nil { - if errors.Is(err, pluginapi.ErrNotFound) { - return nil - } - return err - } - return nil -} - -// detachAndDeleteCustomScheme retires the custom scheme a failed CreateSpace left behind. The -// doomed backing channel still points at the scheme, and core counts even a soon-to-be-archived -// space channel as a live reference that blocks the delete, so the channel's scheme reference is -// cleared first through Channel.Update and only then is the now-unreferenced scheme deleted. -// -// Best-effort: any failure is logged, since the caller is already unwinding an earlier error. -func (s *Service) detachAndDeleteCustomScheme(channelID, schemeID string) { - channel, err := s.client.Channel.GetChannelOfType(channelID, mmmodel.ChannelTypeSpace) - switch { - case errors.Is(err, pluginapi.ErrNotFound): - // No channel row exists, so nothing references the scheme and the delete below succeeds - // without a detach. - case err != nil: - s.log.Error("failed to load abandoned backing channel to detach its custom scheme; the scheme may leak and must be deleted manually", "channel_id", channelID, "scheme_id", schemeID, "err", err) - return - case channel != nil && channel.SchemeId != nil && *channel.SchemeId == schemeID: - channel.SchemeId = nil - if updErr := s.client.Channel.Update(channel); updErr != nil { - s.log.Error("failed to detach custom scheme from abandoned backing channel; the scheme may leak and must be deleted manually", "channel_id", channelID, "scheme_id", schemeID, "err", updErr) - return - } - } - - if err := s.retireSpaceCustomScheme(schemeID); err != nil { - s.log.Error("failed to retire space custom scheme after a failed create; it must be deleted manually", "scheme_id", schemeID, "err", err) - } -} diff --git a/server/app/service.go b/server/app/service.go index 6560224..84b9571 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -194,9 +194,10 @@ func invalidInputAppError(where string, err error) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.page_draft.update.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) case store.ReasonDraftTooDeep: return mmmodel.NewAppError(where, "app.page_draft.update.parent_too_deep.app_error", nil, "", http.StatusBadRequest).Wrap(err) - } - if invErr.Reason == store.ReasonSubtreeNotOwned { - return mmmodel.NewAppError(where, "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonSubtreeNotOwned: + // An authorization denial, not a malformed request: the caller holds only + // delete_own_page and the moved subtree contains a page they do not own. + return mmmodel.NewAppError(where, "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusForbidden).Wrap(err) } return mmmodel.NewAppError(where, invErr.Reason, nil, "", http.StatusBadRequest).Wrap(err) } diff --git a/server/app/space.go b/server/app/space.go index aa91358..cdf3dd4 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -134,18 +134,14 @@ func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) { // resolveSpaceScheme picks the backing-channel scheme that gives a space's plain members the // requested capabilities. A set matching one of the seeded presets resolves to that preset's -// scheme, which is shared by every space using the same preset. Any other set gets a scheme -// created here and used by this space alone. +// scheme; any other set resolves to a scheme in the shared pool keyed by the set itself, created +// on first use and thereafter shared by every space configured that way. // -// A custom scheme is returned with its generated roles still carrying core's default channel -// baseline; the caller gives them their exact permission sets through configureSpaceCustomScheme -// once a backing channel points at the scheme, which is what lets core admit the role writes. -// -// customRoles is non-nil only in the second case, and names the roles that configure step must -// write. Its nil-ness also tells the two cases apart when a later step fails: a scheme created here -// is referenced by nothing else, so the caller must delete it (see cleanupCustomScheme), while a -// preset scheme is shared with other spaces and must be left alone. -func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, customRoles *schemeRoles, err error) { +// pooledRoles is non-nil for the pooled case and names the roles the caller must write through +// configureSharedScheme once a backing channel points at the scheme — which is what lets core +// admit those writes. A preset's roles are seeded already and are never rewritten, so the preset +// case returns nil. Nothing here is owned by one space, so no failure path deletes a scheme. +func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, pooledRoles *schemeRoles, err error) { // Normalize before the permission set is persisted: the validators are dedup-tolerant, so // without this a request repeating one allowlisted token would write that repetition verbatim // into the generated role's Permissions column. @@ -157,31 +153,7 @@ func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, cu } return id, nil, nil } - return s.createSpaceCustomScheme() -} - -// cleanupCustomScheme best-effort retires schemeID when createdCustom is true and a create-step -// fails before any channel references the scheme (channel create never happened, or the repoint -// onto it failed), so the scheme is already unreferenced and DeleteScheme accepts it directly. -func (s *Service) cleanupCustomScheme(schemeID string, createdCustom bool) { - if !createdCustom { - return - } - if err := s.retireSpaceCustomScheme(schemeID); err != nil { - s.log.Error("failed to retire space custom scheme after a failed create; it must be deleted manually", "scheme_id", schemeID, "err", err) - } -} - -// abandonBackingChannel runs the compensating cleanup shared by every CreateSpace failure that -// happens after the backing channel exists: retire the custom scheme it was created for, then -// archive the channel. The scheme retirement clears the channel's scheme reference first, because -// archiving alone would not clear it and core still counts an archived space channel as a live -// reference that blocks the delete (see detachAndDeleteCustomScheme). -func (s *Service) abandonBackingChannel(channelID, reason string, cause error, schemeID string, createdCustom bool) { - if createdCustom { - s.detachAndDeleteCustomScheme(channelID, schemeID) - } - s.archiveOrphanChannel(channelID, reason, cause) + return s.getOrCreateSharedScheme(capabilities) } // CreateSpace creates a ChannelTypeSpace ("S") backing channel via pluginapi, saves the @@ -195,7 +167,7 @@ func (s *Service) abandonBackingChannel(channelID, reason string, cause error, s // between them leaves a real channel with no space row and no persisted marker to key a retry // off, so that window is cleaned up only by the best-effort compensating archive below (or an // operator, if that also fails). -func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabilities *[]string, viewAccess *string) (*model.Space, *mmmodel.AppError) { +func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabilities *[]string, viewAccess *model.ViewAccess) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.nil_input.app_error", nil, "", http.StatusBadRequest) } @@ -264,11 +236,10 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili return nil, capErr } - schemeID, customRoles, schemeErr := s.resolveSpaceScheme(capabilities) + schemeID, pooledRoles, schemeErr := s.resolveSpaceScheme(capabilities) if schemeErr != nil { return nil, storeAppError("CreateSpace", schemeErr) } - createdCustom := customRoles != nil s.log.Debug("Creating space", "team_id", space.TeamId, "user_id", userID) @@ -285,10 +256,7 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili // backingChannel before its post-create bookkeeping, so an error alongside a populated // Id means the channel row already exists and must be archived, not leaked. if backingChannel.Id != "" { - s.abandonBackingChannel(backingChannel.Id, "channel create failed after creation", err, schemeID, createdCustom) - } else { - // No channel row exists, so nothing references the scheme. - s.cleanupCustomScheme(schemeID, createdCustom) + s.archiveOrphanChannel(backingChannel.Id, "channel create failed after creation", err) } return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.backing_channel_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -296,9 +264,9 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili // Only now that the backing channel points at the scheme will core admit role writes carrying // space permissions, so a freshly created custom scheme gets its exact permission sets here // rather than at create time. - if createdCustom { - if cfgErr := s.configureSpaceCustomScheme(customRoles, capabilities); cfgErr != nil { - s.abandonBackingChannel(backingChannel.Id, "custom scheme role configuration failed", cfgErr, schemeID, createdCustom) + if pooledRoles != nil { + if cfgErr := s.configureSharedScheme(pooledRoles, capabilities); cfgErr != nil { + s.archiveOrphanChannel(backingChannel.Id, "pooled scheme role configuration failed", cfgErr) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) } } @@ -307,7 +275,7 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili // A space whose creator is not a member of its backing channel is a dead-end once per-space // membership gating lands (unreachable to everyone, creator included), so fail the create // and archive the orphan channel rather than continuing. - s.abandonBackingChannel(backingChannel.Id, "creator member-add failed", addErr, schemeID, createdCustom) + s.archiveOrphanChannel(backingChannel.Id, "creator member-add failed", addErr) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.add_member_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(addErr) } @@ -317,11 +285,11 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili // rejects a string that leaves SchemeUser unset). resolvedRoles, rolesErr := s.getSchemeRolesForChannel(backingChannel.Id) if rolesErr != nil { - s.abandonBackingChannel(backingChannel.Id, "scheme role lookup failed", rolesErr, schemeID, createdCustom) + s.archiveOrphanChannel(backingChannel.Id, "scheme role lookup failed", rolesErr) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(rolesErr) } if _, roleErr := s.client.Channel.UpdateChannelMemberRoles(backingChannel.Id, userID, resolvedRoles.UserRoleName+" "+resolvedRoles.AdminRoleName); roleErr != nil { - s.abandonBackingChannel(backingChannel.Id, "creator admin role assignment failed", roleErr, schemeID, createdCustom) + s.archiveOrphanChannel(backingChannel.Id, "creator admin role assignment failed", roleErr) return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.admin_role_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(roleErr) } @@ -329,13 +297,23 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili saved, err := s.store.CreateSpace(space) if err != nil { - s.abandonBackingChannel(backingChannel.Id, "row save failed", err, schemeID, createdCustom) + s.archiveOrphanChannel(backingChannel.Id, "row save failed", err) return nil, storeAppError("CreateSpace", err) } s.publishToChannels(wsEventSpaceCreated, map[string]any{"space_id": saved.Id}, saved.ChannelId) - return saved, nil + // Both halves of the access state are already settled here, so the wrapper is projected from + // what this call established rather than re-resolved: defaultCapabilities is the set just + // applied to the scheme, and the creator was assigned SchemeAdmin above — a step whose failure + // aborts the create, so reaching this point means the creator holds the full admin set. + wrapper := &model.SpaceWithAccess{ + Space: *saved, + DefaultCapabilities: model.NormalizeCapabilitySet(capabilities), + Capabilities: model.AdminEffectiveCapabilities(), + } + wrapper.EnsureCapabilities() + return wrapper, nil } // GetSpace returns the live space with the given ID. @@ -483,11 +461,10 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ return nil } - targetSchemeID, customRoles, schemeErr := s.resolveSpaceScheme(capabilities) + targetSchemeID, pooledRoles, schemeErr := s.resolveSpaceScheme(requested) if schemeErr != nil { return storeAppError("SetSpaceDefaultCapabilities", schemeErr) } - createdCustom := customRoles != nil if targetSchemeID == currentSchemeID { // No-op: requested set already matches the live default. return nil @@ -495,42 +472,33 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ channel.SchemeId = &targetSchemeID if updErr := s.client.Channel.Update(channel); updErr != nil { - s.cleanupCustomScheme(targetSchemeID, createdCustom) return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.repoint_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) } // The repoint above is what lets core admit role writes carrying space permissions, so a - // freshly created custom scheme is configured only now. A failure leaves the space on a - // scheme whose roles still hold core's default channel baseline, so the repoint is undone - // before the unusable scheme is retired. - if createdCustom { - if cfgErr := s.configureSpaceCustomScheme(customRoles, capabilities); cfgErr != nil { + // pooled scheme's roles are written only now. A failure leaves the space on a scheme whose + // roles may still hold core's default channel baseline, so the repoint is undone. + // + // Between the repoint and the configure below, a concurrent page-permission check in this + // space resolves against the new scheme's roles, which on the pool's first use still hold + // that baseline, so a member can be denied a page write they hold. The membership lock does + // not close this: it serializes other membership and scheme mutations, not ordinary + // permission checks, which read core's roles directly. The ordering is forced — core admits + // a role write carrying space permissions only once a channel already references the scheme + // — so the window is accepted rather than closed. It is bounded by the role writes below, + // self-heals, and does not recur once the pooled scheme is configured. + if pooledRoles != nil { + if cfgErr := s.configureSharedScheme(pooledRoles, requested); cfgErr != nil { channel.SchemeId = ¤tSchemeID if rollbackErr := s.client.Channel.Update(channel); rollbackErr != nil { - s.log.Error("failed to restore the previous space scheme after a failed custom-scheme configuration; the space is left on an unconfigured scheme", "channel_id", space.ChannelId, "scheme_id", targetSchemeID, "previous_scheme_id", currentSchemeID, "err", rollbackErr) - } else { - s.cleanupCustomScheme(targetSchemeID, createdCustom) + s.log.Error("failed to restore the previous space scheme after a failed scheme configuration; the space is left on a scheme whose roles may be unconfigured", "channel_id", space.ChannelId, "scheme_id", targetSchemeID, "previous_scheme_id", currentSchemeID, "err", rollbackErr) } return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) } } - // Retire the superseded scheme, but only inside this lock (a concurrent repoint between an - // outside-the-lock read and the retire call could otherwise delete a scheme a racing - // SetSpaceDefaultCapabilities just repointed away from) and only when it was a - // space-private custom one — a routine preset-to-preset switch must neither call the - // retire path nor log a spurious failure. Preset membership is settled by scheme id rather - // than by the projected capability set, which cannot tell a preset apart from a custom - // scheme whose roles were never given their permission sets. - supersededIsPreset, presetErr := s.isPresetSchemeID(currentSchemeID) - switch { - case presetErr != nil: - s.log.Warn("failed to determine whether the superseded space scheme is a preset; skipping custom-scheme retirement", "scheme_id", currentSchemeID, "err", presetErr) - case !supersededIsPreset: - if delErr := s.retireSpaceCustomScheme(currentSchemeID); delErr != nil { - s.log.Error("failed to retire space custom scheme after a default-capabilities change; it must be deleted manually", "scheme_id", currentSchemeID, "err", delErr) - } - } + // The superseded scheme is left in place: presets and pooled schemes alike are shared by + // every space expressing that capability set, so none is this space's to delete. return nil }) if lockErr != nil { diff --git a/server/app/space_members.go b/server/app/space_members.go index 877f3ca..3620bd5 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -108,7 +108,11 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac } return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": member.UserId}, space.ChannelId) + payload := map[string]any{"space_id": space.Id, "user_id": member.UserId} + s.publishToChannels(wsEventSpaceMemberAdded, payload, space.ChannelId) + // Also delivered directly: the channel-scoped broadcast may not resolve a member added moments + // earlier, and the new member has no other signal that they now have the space. + s.publishToUser(wsEventSpaceMemberAdded, payload, member.UserId) return toSpaceMember(member, defaultCapabilities), nil } @@ -120,6 +124,21 @@ func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID stri return s.hasOtherAuthorizedMemberMatching(space, excludeUserID, func(cm *mmmodel.ChannelMember) bool { return cm.SchemeAdmin }) } +// requireNotLastAdmin rejects an operation that would leave space without an admin who can still +// reach it, disregarding excludeUserID (the member being demoted or removed). Callers run it inside +// the space-keyed membership lock, alongside the mutation it guards. where attributes both the +// lookup failure and the rejection to the calling operation. +func (s *Service) requireNotLastAdmin(where string, space *model.Space, excludeUserID string) *mmmodel.AppError { + otherAdmin, err := s.hasOtherAuthorizedAdmin(space, excludeUserID) + if err != nil { + return mmmodel.NewAppError(where, "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if !otherAdmin { + return mmmodel.NewAppError(where, "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) + } + return nil +} + // SetSpaceMemberCapabilities replaces targetUserID's per-member granted capability set. Callers // must already hold manage-tier authority over the space (RequireSpaceAdminOrTeamPerm with // manage_space); this method @@ -186,14 +205,9 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st } if target.SchemeAdmin && !newSchemeAdmin { - otherAdmin, cntErr := s.hasOtherAuthorizedAdmin(space, targetUserID) - if cntErr != nil { - appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cntErr) - return appErr - } - if !otherAdmin { - appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) - return appErr + if e := s.requireNotLastAdmin("SetSpaceMemberCapabilities", space, targetUserID); e != nil { + appErr = e + return e } } @@ -296,14 +310,9 @@ func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID str appErr = e return e } - otherAdmin, cntErr := s.hasOtherAuthorizedAdmin(space, userID) - if cntErr != nil { - appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cntErr) - return appErr - } - if !otherAdmin { - appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) - return appErr + if e := s.requireNotLastAdmin("RemoveSpaceMember", space, userID); e != nil { + appErr = e + return e } } hasOther, guardErr := s.hasOtherAuthorizedMember(space, userID) diff --git a/server/app/space_test.go b/server/app/space_test.go index 0cbcda0..8167f36 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -286,7 +286,7 @@ func TestServiceDeleteSpace_ArchivesBackingChannel(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) // The space is soft-deleted: a live read no longer finds it. @@ -318,7 +318,7 @@ func TestServiceDeleteSpace_ArchiveFailureTolerated(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space), "DeleteSpace must succeed even though the channel archive fails") + require.Nil(t, h.svc.DeleteSpace(&space.Space), "DeleteSpace must succeed even though the channel archive fails") mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) // The space is soft-deleted regardless of the channel-archive failure. @@ -356,7 +356,7 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) _, appErr = h.svc.RestoreSpace(space.Id) require.NotNil(t, appErr, "RestoreSpace must fail when the channel un-archive fails") @@ -399,7 +399,7 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Stuck Restore"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) _, appErr = h.svc.RestoreSpace(space.Id) require.NotNil(t, appErr, "the first RestoreSpace must fail on the channel un-archive") @@ -442,7 +442,7 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Never Archived"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) restored, appErr := h.svc.RestoreSpace(space.Id) require.Nil(t, appErr, "RestoreSpace must succeed when the backing channel was never archived") @@ -548,6 +548,7 @@ func TestServiceCreateSpace_AddMemberFailedCompensates(t *testing.T) { // the new scheme's id. func stubCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { t.Helper() + testutil.StubPooledSchemeMiss(mockAPI) schemeID := mmmodel.NewId() userRole, adminRole, guestRole := "custom_user_role", "custom_admin_role", "custom_guest_role" mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ @@ -566,12 +567,12 @@ func stubCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { return schemeID } -// TestServiceCreateSpace_CustomSchemeAbandonedOnLaterFailure covers the compensating path a -// non-preset default-capability set takes when a later create step fails: the custom scheme must be -// detached from the doomed backing channel BEFORE it is deleted, because core counts even a -// soon-to-be-archived space channel as a live reference that refuses the delete. A preset-backed -// create never reaches this path, so without a non-preset set the whole branch goes unexercised. -func TestServiceCreateSpace_CustomSchemeAbandonedOnLaterFailure(t *testing.T) { +// TestServiceCreateSpace_PooledSchemeSurvivesAbandon covers the compensating path a non-preset +// default-capability set takes when a later create step fails: the doomed backing channel is +// archived, but the pooled scheme it pointed at is left alone. The pool is keyed by the capability +// set, so that scheme is not this space's to delete — another space may already be resolving to it, +// and deleting it would strip their members' capabilities. +func TestServiceCreateSpace_PooledSchemeSurvivesAbandon(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) @@ -580,8 +581,8 @@ func TestServiceCreateSpace_CustomSchemeAbandonedOnLaterFailure(t *testing.T) { backingChannelID := mmmodel.NewId() channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) - customSchemeID := stubCustomSchemeCreate(t, mockAPI) - // CreateChannel attaches the custom scheme, which is what lets core admit the role writes. + pooledSchemeID := stubCustomSchemeCreate(t, mockAPI) + // CreateChannel attaches the pooled scheme, which is what lets core admit the role writes. mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Run(func(args mock.Arguments) { created, ok := args.Get(0).(*mmmodel.Channel) @@ -592,17 +593,16 @@ func TestServiceCreateSpace_CustomSchemeAbandonedOnLaterFailure(t *testing.T) { Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) - mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, nil) mockAPI.On("DeleteChannel", backingChannelID).Return(nil) _, appErr := h.svc.CreateSpace( - &model.Space{TeamId: teamID, Title: "Doomed Custom"}, userID, &[]string{"create_page"}, nil) + &model.Space{TeamId: teamID, Title: "Doomed Pooled"}, userID, &[]string{"create_page"}, nil) require.NotNil(t, appErr) require.Equal(t, "app.space.create.add_member_failed.app_error", appErr.Id) - require.Nil(t, channel.SchemeId, "the doomed channel's scheme reference must be cleared before the scheme is deleted") - mockAPI.AssertCalled(t, "DeleteScheme", customSchemeID) mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) + require.NotEmpty(t, pooledSchemeID) } // TestServiceCreateSpace_PresetSchemeSurvivesAbandon is the negative half of the case above: a @@ -646,15 +646,17 @@ func TestServiceCreateSpace_CustomSchemeConfiguredAfterChannelAttach(t *testing. // (mock.Mock matches in registration order) and can record the permission set per role. channelAttached := false patched := map[string][]string{} - mockAPI.On("PatchRole", mock.AnythingOfType("*model.Role"), mock.AnythingOfType("*model.RolePatch")). + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). Run(func(args mock.Arguments) { require.True(t, channelAttached, "roles must be patched only after the channel attaches the scheme") - role, ok := args.Get(0).(*mmmodel.Role) + roleID, ok := args.Get(0).(string) require.True(t, ok) + roleName, ok := testutil.StubbedRoleName(roleID) + require.True(t, ok, "PatchRole called with an unregistered role id %q", roleID) patch, ok := args.Get(1).(*mmmodel.RolePatch) require.True(t, ok) require.NotNil(t, patch.Permissions) - patched[role.Name] = *patch.Permissions + patched[roleName] = *patch.Permissions }). Return(&mmmodel.Role{}, nil) @@ -688,6 +690,7 @@ func TestServiceCreateSpace_CustomSchemeConfiguredAfterChannelAttach(t *testing. // scheme's id. func stubCustomSchemeCreateFailingPatch(t *testing.T, mockAPI *plugintest.API) string { t.Helper() + testutil.StubPooledSchemeMiss(mockAPI) schemeID := mmmodel.NewId() userRole, adminRole, guestRole := "unconfigurable_user_role", "unconfigurable_admin_role", "unconfigurable_guest_role" mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ @@ -702,16 +705,17 @@ func stubCustomSchemeCreateFailingPatch(t *testing.T, mockAPI *plugintest.API) s testutil.StubRole(mockAPI, userRole, nil) testutil.StubRole(mockAPI, adminRole, nil) testutil.StubRole(mockAPI, guestRole, nil) - mockAPI.On("PatchRole", mock.AnythingOfType("*model.Role"), mock.AnythingOfType("*model.RolePatch")). + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) return schemeID } -// TestServiceCreateSpace_CustomSchemeConfigureFailureAbandons covers the CreateSpace branch that -// runs when the role writes fail after the backing channel already carries the new scheme: the -// create fails, and the same detach-then-delete-then-archive cleanup runs, so neither the scheme -// nor the channel is left behind. The creator is never added, since configuration precedes that. -func TestServiceCreateSpace_CustomSchemeConfigureFailureAbandons(t *testing.T) { +// TestServiceCreateSpace_PooledSchemeConfigureFailureAbandons covers the CreateSpace branch that +// runs when the role writes fail after the backing channel already carries the pooled scheme: the +// create fails and the channel is archived, while the pooled scheme stays — it is shared, so a +// later space resolving to the same capability set reconfigures it. The creator is never added, +// since configuration precedes that. +func TestServiceCreateSpace_PooledSchemeConfigureFailureAbandons(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) @@ -729,17 +733,16 @@ func TestServiceCreateSpace_CustomSchemeConfigureFailureAbandons(t *testing.T) { channel.SchemeId = created.SchemeId }). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, nil) mockAPI.On("DeleteChannel", backingChannelID).Return(nil) _, appErr := h.svc.CreateSpace( - &model.Space{TeamId: teamID, Title: "Unconfigurable Custom"}, userID, &[]string{"create_page"}, nil) + &model.Space{TeamId: teamID, Title: "Unconfigurable Pooled"}, userID, &[]string{"create_page"}, nil) require.NotNil(t, appErr) require.Equal(t, "app.space.create.scheme_configure_failed.app_error", appErr.Id) - require.Nil(t, channel.SchemeId, "the doomed channel's scheme reference must be cleared before the scheme is deleted") - mockAPI.AssertCalled(t, "DeleteScheme", customSchemeID) mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) + require.NotEmpty(t, customSchemeID) mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) spaces, err := h.store.GetSpacesForTeam(teamID, userID, false, 0, 10) @@ -748,44 +751,27 @@ func TestServiceCreateSpace_CustomSchemeConfigureFailureAbandons(t *testing.T) { } // TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack covers the repoint-then-configure -// failure branch: a space whose new custom scheme cannot be configured must be put back on its -// previous scheme rather than left on one whose roles still carry core's default channel baseline. +// failure branch: a space whose newly pooled scheme cannot be configured must be put back on its +// previous scheme rather than left on one whose roles may still carry core's default channel +// baseline. The pooled scheme itself is never deleted — it is shared, not this space's to retire. func TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack(t *testing.T) { - setup := func(t *testing.T, deleteSchemeErr *mmmodel.AppError) (*mmmodel.Channel, string, *mmmodel.AppError) { - t.Helper() - mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - - channelID := mmmodel.NewId() - channel := testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) - customSchemeID := stubCustomSchemeCreateFailingPatch(t, mockAPI) - mockAPI.On("DeleteScheme", customSchemeID).Return(&mmmodel.Scheme{Id: customSchemeID}, deleteSchemeErr) - - space := mustCreateSpace(t, h.store, h.db, channelID) - _, appErr := h.svc.SetSpaceDefaultCapabilities(space, []string{"create_page"}, mmmodel.NewId()) - return channel, customSchemeID, appErr - } - - t.Run("restores the previous scheme and retires the unconfigured one", func(t *testing.T) { - channel, customSchemeID, appErr := setup(t, nil) + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) - require.NotNil(t, appErr) - require.Equal(t, "app.space.default_capabilities.scheme_configure_failed.app_error", appErr.Id) - require.NotNil(t, channel.SchemeId) - require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, - "the channel must be repointed back at the scheme it started on") - require.NotEqual(t, customSchemeID, *channel.SchemeId) - }) + channelID := mmmodel.NewId() + channel := testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + pooledSchemeID := stubCustomSchemeCreateFailingPatch(t, mockAPI) - t.Run("still reports the configure failure when the retire is refused", func(t *testing.T) { - channel, _, appErr := setup(t, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusBadRequest}) + space := mustCreateSpace(t, h.store, h.db, channelID) + _, appErr := h.svc.SetSpaceDefaultCapabilities(space, []string{"create_page"}, mmmodel.NewId()) - require.NotNil(t, appErr) - require.Equal(t, "app.space.default_capabilities.scheme_configure_failed.app_error", appErr.Id) - require.NotNil(t, channel.SchemeId) - require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, - "a refused retire must not leave the channel on the unconfigured scheme") - }) + require.NotNil(t, appErr) + require.Equal(t, "app.space.default_capabilities.scheme_configure_failed.app_error", appErr.Id) + require.NotNil(t, channel.SchemeId) + require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, + "the channel must be repointed back at the scheme it started on") + require.NotEqual(t, pooledSchemeID, *channel.SchemeId) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) } // TestServiceRestoreSpace_UnarchivesBackingChannel verifies a create→delete→restore round trip @@ -808,7 +794,7 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) got, appErr := h.svc.RestoreSpace(space.Id) require.Nil(t, appErr) @@ -916,7 +902,7 @@ func TestGetSpaceWithDeleted(t *testing.T) { space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) // GetSpace excludes deleted rows. _, appErr = h.svc.GetSpace(space.Id) @@ -982,7 +968,7 @@ func createSpaceForMemberTests(t *testing.T, h *testHarness, mockAPI *plugintest space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, creatorID, nil, nil) require.Nil(t, appErr) - return space, creatorID + return &space.Space, creatorID } // TestServiceGetSpaceMembers_ListFails verifies that a failed member listing on the backing diff --git a/server/app/ws_events.go b/server/app/ws_events.go index aa54baf..12634c4 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -21,7 +21,11 @@ import ( // source space's half (source space and old parent), the target channel only the target's half — // rather than one payload naming both spaces. A member removal is additionally sent to the removed // user directly, who has already left the channel when the channel-scoped broadcast fires and -// would otherwise never learn of it. space_deleted is likewise delivered to each member +// would otherwise never learn of it. space_member_added and space_member_capabilities_updated are +// sent to the affected user directly for the mirror-image reason: the channel-scoped broadcast +// resolves recipients on a space ("S") channel, which may not yet include a member added moments +// earlier, and that user has no other signal that their access changed. +// space_deleted is likewise delivered to each member // directly, from a snapshot taken before the backing channel is archived — channel-scoped // delivery resolves recipients from live channels only, so a broadcast to the just-archived // channel would reach nobody. A space delete or restore cascades to every live page in diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index f73cc51..9096d6d 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -638,3 +638,51 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { map[string]any{"space_id": space.Id, "user_id": targetID}, &mmmodel.WebsocketBroadcast{UserId: targetID}) } + +// TestServiceAddSpaceMember_PublishesToChannelAndUser pins space_member_added's two delivery +// targets: the channel-scoped broadcast for observers, and the direct publish that reaches the +// added user, whom the channel-scoped resolution may not yet include. +func TestServiceAddSpaceMember_PublishesToChannelAndUser(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, _ := createSpaceForMemberTests(t, h, mockAPI) + targetUserID := mmmodel.NewId() + + mockAPI.On("AddChannelMember", space.ChannelId, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetUserID}, nil) + + _, appErr := h.svc.AddSpaceMember(space, targetUserID) + require.Nil(t, appErr) + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, + &mmmodel.WebsocketBroadcast{ChannelId: space.ChannelId}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, + &mmmodel.WebsocketBroadcast{UserId: targetUserID}) +} + +// TestServiceSetSpaceMemberCapabilities_PublishesToChannelAndUser pins +// space_member_capabilities_updated's two delivery targets. The direct publish is what guarantees +// the target learns its own capabilities changed, so a regression to channel-only delivery would +// leave exactly the affected user uninformed. +func TestServiceSetSpaceMemberCapabilities_PublishesToChannelAndUser(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, actingUserID := createSpaceForMemberTests(t, h, mockAPI) + targetUserID := mmmodel.NewId() + + // The acting user must clear the admin-affecting escalation guard. + mockAPI.On("HasPermissionToChannel", actingUserID, space.ChannelId, mmmodel.PermissionAdminSpace).Return(true) + mockAPI.On("GetChannelMember", space.ChannelId, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetUserID}, nil) + + _, appErr := h.svc.SetSpaceMemberCapabilities(space, targetUserID, + []string{model.CapabilityEditPage}, actingUserID) + require.Nil(t, appErr) + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", payload, + &mmmodel.WebsocketBroadcast{UserId: targetUserID}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", payload, + &mmmodel.WebsocketBroadcast{ChannelId: space.ChannelId}) +} diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index b0f294f..84586d7 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -476,8 +476,9 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusForbidden, status, "custom default excludes delete_own_page: %s", body) - // Switching to the read-only preset repoints the backing channel at the shared preset and - // retires the now-unreferenced custom scheme (DeleteScheme); the member loses create. + // Switching to the read-only preset repoints the backing channel at that preset's scheme; + // the pooled scheme it leaves behind stays for the next space to request the same set. The + // member loses create. var roResp pluginmodel.SpaceWithAccess status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/default-capabilities", map[string][]string{"default_capabilities": {}}, &roResp) diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go index 46d9791..bacf371 100644 --- a/server/internal/testutil/schemestub.go +++ b/server/internal/testutil/schemestub.go @@ -4,6 +4,8 @@ package testutil import ( + "net/http" + "strings" "sync" "testing" @@ -129,20 +131,145 @@ func StubPresetSchemes(mockAPI *plugintest.API) { } } +// roleRegistry maps a stubbed role's id to the shared *Role object, so a PatchRole stub keyed by +// id (the shape core's PatchRole takes) reaches the same object a GetRoleByName stub hands out. +// Keyed by generated ids, so entries never collide across tests. Guarded because a test binary may +// run packages in parallel. +var roleRegistry = struct { + sync.RWMutex + byID map[string]*mmmodel.Role + byName map[string]*mmmodel.Role +}{byID: map[string]*mmmodel.Role{}, byName: map[string]*mmmodel.Role{}} + +func roleByID(roleID string) (*mmmodel.Role, bool) { + roleRegistry.RLock() + defer roleRegistry.RUnlock() + role, ok := roleRegistry.byID[roleID] + return role, ok +} + +// roleByNameOrCreate returns the shared *Role for roleName, minting an empty one on first ask. Used +// for roles a test never names directly because the pool generates them from a scheme name. +func roleByNameOrCreate(roleName string) *mmmodel.Role { + roleRegistry.Lock() + defer roleRegistry.Unlock() + if role, ok := roleRegistry.byName[roleName]; ok { + return role + } + role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName} + roleRegistry.byName[roleName] = role + roleRegistry.byID[role.Id] = role + return role +} + +// StubbedRoleName resolves a stubbed role's id back to its name, so a test asserting on PatchRole — +// which core takes by id — can still express its expectation in role names. +func StubbedRoleName(roleID string) (string, bool) { + role, ok := roleByID(roleID) + if !ok { + return "", false + } + return role.Name, true +} + +// pooledSchemeNamePrefix mirrors the plugin-internal prefix every shared default-capability scheme +// carries. Restated here rather than imported because the plugin's copy is unexported. +const pooledSchemeNamePrefix = "docs_space_default_" + +func isPooledSchemeName(name string) bool { return strings.HasPrefix(name, pooledSchemeNamePrefix) } + +// StubPooledSchemeMiss answers the shared-pool lookup with not-found for any pooled scheme name, so +// a test exercising a non-preset default-capability set reaches the create path. Register it after +// StubPresetSchemes, whose per-name stubs must keep matching first, and before any stub that +// answers a specific pooled name. Use StubSchemePool instead when the test needs the pool to +// actually accumulate. +func StubPooledSchemeMiss(mockAPI *plugintest.API) { + mockAPI.On("GetSchemeByName", mock.MatchedBy(isPooledSchemeName)). + Return(nil, &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: http.StatusNotFound}).Maybe() +} + +// StubSchemePool simulates the shared default-capability pool with state: a pooled scheme name +// resolves to not-found until CreateScheme mints it and to that same scheme afterwards. This is +// what lets a test assert the property the pool exists for — that two spaces configured alike, or +// one space returning to a set it used before, resolve to a single scheme instead of minting a +// second identical one. +// +// The generated role names are derived from the scheme name so they can be answered by a standing +// stub; registering new expectations from inside a running mock call would race the mock's own lock. +func StubSchemePool(mockAPI *plugintest.API) { + var mu sync.Mutex + byName := map[string]*mmmodel.Scheme{} + + mockAPI.On("GetSchemeByName", mock.MatchedBy(isPooledSchemeName)). + Return(func(name string) (*mmmodel.Scheme, *mmmodel.AppError) { + mu.Lock() + defer mu.Unlock() + if scheme, ok := byName[name]; ok { + return scheme, nil + } + return nil, &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: http.StatusNotFound} + }).Maybe() + + mockAPI.On("CreateScheme", mock.MatchedBy(func(scheme *mmmodel.Scheme) bool { + return scheme != nil && isPooledSchemeName(scheme.Name) + })).Return(func(in *mmmodel.Scheme) (*mmmodel.Scheme, *mmmodel.AppError) { + mu.Lock() + defer mu.Unlock() + if existing, ok := byName[in.Name]; ok { + return existing, nil + } + scheme := &mmmodel.Scheme{ + Id: mmmodel.NewId(), + Name: in.Name, + DisplayName: in.DisplayName, + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: in.Name + "_user", + DefaultChannelAdminRole: in.Name + "_admin", + DefaultChannelGuestRole: in.Name + "_guest", + } + byName[in.Name] = scheme + RegisterSchemeRoles(scheme.Id, scheme.DefaultChannelGuestRole, scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole) + return scheme, nil + }).Maybe() + + stubPooledRoles(mockAPI) + StubPatchRole(mockAPI) +} + +// stubPooledRoles answers GetRoleByName for any role generated for a pooled scheme, minting the +// shared *Role on first read so a later PatchRole mutation is visible to a subsequent read. +func stubPooledRoles(mockAPI *plugintest.API) { + mockAPI.On("GetRoleByName", mock.MatchedBy(isPooledSchemeName)). + Return(func(roleName string) (*mmmodel.Role, *mmmodel.AppError) { + return roleByNameOrCreate(roleName), nil + }).Maybe() +} + // StubRole registers a GetRoleByName stub returning one shared *Role carrying permissions, so a // PatchRole stub that mutates it (see StubPatchRole) is visible to a later read of the same role — -// the mock does not track state on its own. Returns the shared Role. +// the mock does not track state on its own. The role is given an id and registered under it, since +// production patches by id. Returns the shared Role. func StubRole(mockAPI *plugintest.API, roleName string, permissions []string) *mmmodel.Role { - role := &mmmodel.Role{Name: roleName, Permissions: permissions} + role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName, Permissions: permissions} + roleRegistry.Lock() + roleRegistry.byID[role.Id] = role + roleRegistry.byName[role.Name] = role + roleRegistry.Unlock() mockAPI.On("GetRoleByName", roleName).Return(role, nil).Maybe() return role } // StubPatchRole registers a PatchRole stub applying the patch's permission set to the shared *Role -// the matching GetRoleByName stub returns, so the write is observable to a later read. +// the matching GetRoleByName stub returns, so the write is observable to a later read. Roles are +// looked up by id, mirroring core's own PatchRole, which re-reads the stored role rather than +// trusting one supplied by the caller. func StubPatchRole(mockAPI *plugintest.API) { - mockAPI.On("PatchRole", mock.AnythingOfType("*model.Role"), mock.AnythingOfType("*model.RolePatch")). - Return(func(role *mmmodel.Role, patch *mmmodel.RolePatch) (*mmmodel.Role, *mmmodel.AppError) { + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). + Return(func(roleID string, patch *mmmodel.RolePatch) (*mmmodel.Role, *mmmodel.AppError) { + role, ok := roleByID(roleID) + if !ok { + return nil, &mmmodel.AppError{Id: "app.role.get.app_error", StatusCode: http.StatusNotFound} + } if patch != nil && patch.Permissions != nil { role.Permissions = *patch.Permissions } diff --git a/server/model/page_content.go b/server/model/page_content.go index a7d8728..e4f45a9 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -126,7 +126,7 @@ func writeSearchTextPart(b *strings.Builder, part string) { b.WriteString(part) } -// maxTipTapDepth bounds recursion over client-supplied content. encoding/json already capabilities nesting, +// maxTipTapDepth bounds recursion over client-supplied content. encoding/json already caps nesting, // but this rejects a pathologically deep document before the recursive walk and keeps stored content // within a sane depth. const maxTipTapDepth = 100 diff --git a/server/model/space.go b/server/model/space.go index c735e24..0d4c1b7 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -24,10 +24,15 @@ const ( // channel members (see the app-layer read resolver). ViewAccessPrivate restricts reads to // backing-channel members. There is no third value; PreSave deliberately does not default // ViewAccess — the caller (app layer) always decides it explicitly. - ViewAccessOpen = "open" - ViewAccessPrivate = "private" + ViewAccessOpen ViewAccess = "open" + ViewAccessPrivate ViewAccess = "private" ) +// ViewAccess is a space's non-member read policy, one of ViewAccessOpen/ViewAccessPrivate. The +// defined type keeps the closed vocabulary visible at every call site, mirroring core's ChannelType; +// it marshals as a plain JSON string, so the wire format is a bare "open"/"private". +type ViewAccess string + // Space is stored in the DOCS_Space table. Each space owns a backing MM channel (ChannelId). // A soft-deleted space (DeleteAt>0) retains its pages; pages share the same DeleteAt via a // cascade and can be restored with the space. @@ -42,18 +47,30 @@ type Space struct { Props mmmodel.StringInterface `json:"props"` // ViewAccess is one of ViewAccessOpen/ViewAccessPrivate. It gates non-member reads only — // a member's read is always settled by membership. - ViewAccess string `json:"view_access"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - SortOrder int64 `json:"sort_order"` + ViewAccess ViewAccess `json:"view_access"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + SortOrder int64 `json:"sort_order"` } -// SpaceWithAccess is the response wrapper for GET /spaces/{id} and SetSpaceDefaultCapabilities: it -// carries the caller-relevant access state alongside the plain Space fields. The anonymous Space -// embed keeps the JSON flat, mirroring core's ChannelMemberWithTeamData pattern. DefaultCapabilities -// is the space's default capability set and Capabilities is the caller's own effective set; both are -// read_page-free (the implicit baseline) and non-nil-on-empty. +// SpaceWithAccess carries the caller-relevant access state alongside the plain Space fields. The +// anonymous Space embed keeps the JSON flat, mirroring core's ChannelMemberWithTeamData pattern. +// DefaultCapabilities is the space's default capability set, read_page-free (the implicit +// baseline). Capabilities is the caller's own effective set and does include read_page, since an +// effective set states what the caller may actually do rather than what was granted on top of the +// baseline. Both are non-nil-on-empty. +// +// An endpoint returns this wrapper when it establishes access state (CreateSpace) or changes it +// (SetSpaceDefaultCapabilities), and when it is read directly (GET /spaces/{id}). The endpoints +// that return a bare Space do so because they cannot change either field: a space PATCH alters +// metadata and view_access, neither of which moves the caller's capabilities or the space default, +// and the team listing omits them because resolving a capability set per space would cost a +// scheme-and-role lookup per row. +// +// Because the embed is flat, a bare Space and this wrapper are indistinguishable to a client that +// types them alike: a client caching a space must merge a bare-Space response into its cached +// entry rather than replace it, or it will drop the capability fields an earlier read supplied. type SpaceWithAccess struct { Space DefaultCapabilities []string `json:"default_capabilities"` @@ -78,7 +95,7 @@ type SpacePatch struct { Description *string `json:"description"` Icon *string `json:"icon"` Props *mmmodel.StringInterface `json:"props"` - ViewAccess *string `json:"view_access"` + ViewAccess *ViewAccess `json:"view_access"` } // SpaceMember is the API-facing view of a user's membership in a space. Membership is backed by @@ -87,9 +104,11 @@ type SpacePatch struct { // ExplicitRoles string, notify props) stay internal. type SpaceMember struct { UserId string `json:"user_id"` - // Capabilities is the member's effective capability set (space default union granted). + // Capabilities is the member's effective capability set (space default union granted), + // including the read_page baseline. Capabilities []string `json:"capabilities"` - // GrantedCapabilities is the member's per-member granted set, beyond the space default. + // GrantedCapabilities is the member's per-member granted set beyond the space default, + // read_page-free since the baseline is never independently granted. GrantedCapabilities []string `json:"granted_capabilities"` IsAdmin bool `json:"is_admin"` IsGuest bool `json:"is_guest"` diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index 2ed524e..2587730 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -4,6 +4,8 @@ package model import ( + "crypto/sha256" + "encoding/hex" "maps" "net/http" "slices" @@ -87,11 +89,12 @@ var spaceAdminEffectiveCapabilities = mmmodel.PermissionIDs(mmmodel.SpaceAdminRo // presetCapabilitySets are the three seeded default-capability presets in wire form (read_page- // free — the baseline is implicit and never listed), single-sourced from core's canonical -// permission slices. +// permission slices. Stored already normalized so the lookups below compare and copy without +// re-deriving the canonical form on every call. var presetCapabilitySets = map[string][]string{ - mmmodel.SchemeNameSpaceContribute: stripReadPage(mmmodel.SpaceDefaultContributePermissions), - mmmodel.SchemeNameSpaceComment: stripReadPage(mmmodel.SpaceDefaultCommentPermissions), - mmmodel.SchemeNameSpaceReadOnly: stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions), + mmmodel.SchemeNameSpaceContribute: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultContributePermissions)), + mmmodel.SchemeNameSpaceComment: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultCommentPermissions)), + mmmodel.SchemeNameSpaceReadOnly: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions)), } // validateCapabilities validates capabilities against allowed, rejecting read_page as the non-grantable @@ -229,7 +232,39 @@ func DefaultCapabilitiesForSchemeName(name string) ([]string, bool) { if !ok { return nil, false } - return NormalizeCapabilitySet(capabilities), true + // Cloned, not returned directly: the presets are package state a caller must not be able to + // mutate through the returned slice. + return slices.Clone(capabilities), true +} + +// sharedSchemeNamePrefix labels every scheme in the shared default-capability pool. The suffix is +// derived from the capability set, so one scheme serves every space configured that way. +const sharedSchemeNamePrefix = "docs_space_default_" + +// sharedSchemeDisplayNamePrefix opens the operator-facing name of a pooled scheme, which then lists +// the capability set the scheme grants. +const sharedSchemeDisplayNamePrefix = "Space defaults: " + +// SharedSchemeNameForCapabilities returns the pool scheme name expressing capabilities: a +// deterministic function of the capability set, so two spaces configured the same way resolve to +// one shared scheme rather than each owning an identical private copy. The suffix is a digest +// rather than the tokens themselves, which keeps the name inside core's 64-character +// [a-z0-9_] limit and — unlike a positional encoding — leaves existing names meaning what they +// always meant when the capability vocabulary grows. +func SharedSchemeNameForCapabilities(capabilities []string) string { + sum := sha256.Sum256([]byte(strings.Join(NormalizeCapabilitySet(capabilities), " "))) + return sharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:16] +} + +// SharedSchemeDisplayNameForCapabilities returns the operator-facing name of the pooled scheme for +// capabilities, listing the set so the digest in the scheme name is legible in the System Console. +// Truncated to core's DisplayName limit, which the current vocabulary is far short of. +func SharedSchemeDisplayNameForCapabilities(capabilities []string) string { + name := sharedSchemeDisplayNamePrefix + strings.Join(NormalizeCapabilitySet(capabilities), ", ") + if len(name) > mmmodel.SchemeDisplayNameMaxLength { + name = name[:mmmodel.SchemeDisplayNameMaxLength] + } + return name } // SchemeNameForDefaultCapabilities returns the seeded preset scheme name matching capabilities, or false @@ -238,7 +273,7 @@ func DefaultCapabilitiesForSchemeName(name string) ([]string, bool) { func SchemeNameForDefaultCapabilities(capabilities []string) (string, bool) { normalized := NormalizeCapabilitySet(capabilities) for name, preset := range presetCapabilitySets { - if slices.Equal(NormalizeCapabilitySet(preset), normalized) { + if slices.Equal(preset, normalized) { return name, true } } diff --git a/server/store/page_hierarchy.go b/server/store/page_hierarchy.go index aeb0290..bfbe635 100644 --- a/server/store/page_hierarchy.go +++ b/server/store/page_hierarchy.go @@ -19,7 +19,7 @@ const MaxPageDescendantsLimit = 5000 // Matches MaxPageDescendantsLimit so a full-fan-out subtree still fits the descendant cap. const MaxPageSiblingsLimit = MaxPageDescendantsLimit -// MaxRowsPerQuery capabilities unpaginated reads to prevent unbounded result sets. +// MaxRowsPerQuery caps unpaginated reads to prevent unbounded result sets. // Paginated listings reject a non-positive limit outright rather than falling back to this cap. const MaxRowsPerQuery = 5000 diff --git a/server/store/store.go b/server/store/store.go index b6fcec0..05cbd15 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -168,12 +168,16 @@ func (s *Store) finalizeTransaction(tx *sqlx.Tx, perr *error) { } } -// beginBoundedTx starts a transaction whose connection acquisition is bounded by -// defaultQueryTimeout, and must be used by any transaction that can run while its caller already -// holds WithSpaceMembershipLock's dedicated connection. Such a caller needs a second pooled -// connection while holding one, so an unbounded acquisition can wait forever on a saturated pool -// while itself holding a connection that pool needs in order to drain. The returned cancel must be -// deferred by the caller: it bounds acquisition, and releasing it ends the transaction's context. +// beginBoundedTx starts a transaction bounded by defaultQueryTimeout, and must be used by any +// transaction that can run while its caller already holds WithSpaceMembershipLock's dedicated +// connection. Such a caller needs a second pooled connection while holding one, so an unbounded +// acquisition can wait forever on a saturated pool while itself holding a connection that pool +// needs in order to drain. +// +// The timeout spans the whole transaction, not just connection acquisition: the context is handed +// to BeginTxx, so database/sql rolls the transaction back if it expires mid-flight. Callers must +// therefore finish inside defaultQueryTimeout, not merely start inside it. The returned cancel must +// be deferred, and runs after the commit or rollback that the caller's own defer performs. func (s *Store) beginBoundedTx() (*sqlx.Tx, context.CancelFunc, error) { ctx, cancel := context.WithTimeout(context.Background(), defaultQueryTimeout) tx, err := s.db.BeginTxx(ctx, nil) From b346d653d0bef359f1ceabec96436cea1b9f8778 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 4 Aug 2026 11:48:39 +0200 Subject: [PATCH 21/28] add support for delete permission --- assets/i18n/en.json | 4 -- server/api_handler_test.go | 4 +- server/app/space.go | 29 +++++----- server/app/space_members.go | 2 +- server/app/space_test.go | 4 +- server/e2e/scenarios_test.go | 93 +++++++++++++++++++++++++----- server/model/space_capabilities.go | 23 ++++---- 7 files changed, 111 insertions(+), 48 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 08a4a8d..f0ad0f4 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -799,10 +799,6 @@ "id": "model.space_capabilities.admin_not_a_default.app_error", "translation": "The admin_space capability cannot be a space default." }, - { - "id": "model.space_capabilities.delete_page_not_grantable.app_error", - "translation": "The delete_page capability cannot be granted on its own. Grant admin_space instead." - }, { "id": "model.space_capabilities.read_page_not_grantable.app_error", "translation": "The read_page capability cannot be granted; every space member holds it." diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 23dccf0..9982e5d 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -302,8 +302,8 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { t.Run("move then delete page", func(t *testing.T) { parent := seedPage(t, h.store, space.Id, channelID, "") // Owned by the acting user: a contribute-default member holds only delete_own_page, not - // delete_page (any) — deleting a page owned by someone else is a different, admin-gated - // route, exercised separately. + // delete_page (any) — deleting a page owned by someone else needs that separate grant, + // exercised separately. page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/move", user, map[string]any{ diff --git a/server/app/space.go b/server/app/space.go index cdf3dd4..8c0de50 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -160,8 +160,8 @@ func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, po // space row pointing at it, and adds the creator as a member with SchemeAdmin. space.ChannelId // must be empty — it is set from the created channel. defaultCapabilities nil defaults to the // contribute preset; viewAccess nil defaults to open. If any step after the backing channel's -// creation fails, the backing channel is archived to avoid an orphan, and a newly created custom -// scheme (a non-preset defaultCapabilities) is best-effort retired. +// creation fails, the backing channel is archived to avoid an orphan. Any scheme resolved along the +// way is left alone: presets and pooled schemes are shared, so none is this space's to remove. // // The channel create and the row save are separate systems with no shared transaction: a crash // between them leaves a real channel with no space row and no persisted marker to key a retry @@ -262,7 +262,7 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili } // Only now that the backing channel points at the scheme will core admit role writes carrying - // space permissions, so a freshly created custom scheme gets its exact permission sets here + // space permissions, so a freshly created pooled scheme gets its exact permission sets here // rather than at create time. if pooledRoles != nil { if cfgErr := s.configureSharedScheme(pooledRoles, capabilities); cfgErr != nil { @@ -342,8 +342,8 @@ func (s *Service) GetSpaceWithDeleted(spaceID string) (*model.Space, *mmmodel.Ap // spaceDefaultCapabilities returns space's current default capability set in wire form // (read_page-free): the generated user role's stored permission set projected onto the capability -// vocabulary. The projection covers presets and space-private custom schemes alike, since a -// preset's generated user role carries exactly that preset's capabilities. +// vocabulary. The projection covers presets and pooled schemes alike, since a preset's generated +// user role carries exactly that preset's capabilities. func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) { roles, err := s.getSchemeRolesForChannel(space.ChannelId) if err != nil { @@ -418,9 +418,10 @@ func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*mode } // SetSpaceDefaultCapabilities changes space's default capability set: a set matching a seeded -// preset repoints the backing channel at the shared preset scheme; any other set creates a new -// immutable space-private custom scheme and repoints, retiring the previous custom scheme once -// unreferenced. The repoint goes through pluginapi Channel.Update (not a store-direct write) so +// preset repoints the backing channel at that preset's scheme; any other set repoints it at the +// pooled scheme for that capability set, created on first use and shared by every space configured +// the same way. The superseded scheme is left in place — no scheme belongs to a single space, so +// there is nothing to retire. The repoint goes through pluginapi Channel.Update (not a store-direct write) so // core's member-cache invalidation runs and the new scheme takes effect on the next permission // check, rather than when the cache expires. func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities []string, actingUserID string) (*model.SpaceWithAccess, *mmmodel.AppError) { @@ -451,12 +452,12 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ requested := model.NormalizeCapabilitySet(capabilities) _, requestedIsPreset := model.SchemeNameForDefaultCapabilities(requested) - // A non-preset set always mints a fresh scheme, so the id comparison below could never - // recognize an unchanged custom set; compare the projected capabilities instead. A preset - // request is left to that id comparison, which settles it by scheme identity — the - // projection cannot, because a custom scheme whose roles were never configured projects to - // the same empty set as the read-only preset, and shortcutting there would strand the space - // on that unconfigured scheme with no way to move off it. + // An unchanged non-preset set is settled here, on the projected capabilities, so the no-op + // costs no scheme resolution at all. A preset request is left to the id comparison below, + // which settles it by scheme identity — the projection cannot, because a pooled scheme + // whose roles were never configured projects to the same empty set as the read-only preset, + // and shortcutting there would strand the space on that unconfigured scheme with no way to + // move off it. if !requestedIsPreset && liveCapabilitiesErr == nil && slices.Equal(liveCapabilities, requested) { return nil } diff --git a/server/app/space_members.go b/server/app/space_members.go index 3620bd5..db5b783 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -166,7 +166,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st // the admin count all read state that a concurrent SetSpaceDefaultCapabilities (repoints the // channel's scheme) or SetSpaceMemberCapabilities/RemoveSpaceMember (the last-admin invariant) // call could change: a stale read taken before the lock lets a concurrent default-capabilities - // repoint write a retired scheme's role name, or a concurrent promotion/demotion flip admin + // repoint write a superseded scheme's role name, or a concurrent promotion/demotion flip admin // status, under this operation's feet — so every one of them runs inside the space-keyed // advisory lock, alongside the mutation itself. var appErr *mmmodel.AppError diff --git a/server/app/space_test.go b/server/app/space_test.go index 8167f36..917f4a5 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -553,7 +553,7 @@ func stubCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { userRole, adminRole, guestRole := "custom_user_role", "custom_admin_role", "custom_guest_role" mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ Id: schemeID, - Name: "docs_space_custom_" + mmmodel.NewId(), + Name: "docs_space_default_" + mmmodel.NewId(), Scope: mmmodel.SchemeScopeChannel, DefaultChannelUserRole: userRole, DefaultChannelAdminRole: adminRole, @@ -695,7 +695,7 @@ func stubCustomSchemeCreateFailingPatch(t *testing.T, mockAPI *plugintest.API) s userRole, adminRole, guestRole := "unconfigurable_user_role", "unconfigurable_admin_role", "unconfigurable_guest_role" mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ Id: schemeID, - Name: "docs_space_custom_" + mmmodel.NewId(), + Name: "docs_space_default_" + mmmodel.NewId(), Scope: mmmodel.SchemeScopeChannel, DefaultChannelUserRole: userRole, DefaultChannelAdminRole: adminRole, diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index 84586d7..ff00f98 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -162,7 +162,7 @@ func TestScenarios(t *testing.T) { t.Run("scenario3_private_team_space", func(t *testing.T) { var space pluginmodel.Space status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", - map[string]string{"title": "Scenario Private Team Space", "view_access": pluginmodel.ViewAccessPrivate}, &space) + map[string]any{"title": "Scenario Private Team Space", "view_access": pluginmodel.ViewAccessPrivate}, &space) require.NoError(t, err) require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) spacesToClean = append(spacesToClean, space.Id) @@ -433,12 +433,12 @@ func TestScenarios(t *testing.T) { require.Equal(t, http.StatusForbidden, status, "control delete") }) - // scenario8 exercises the space-private CUSTOM scheme path — a default-capability set matching - // no preset — which the plugin provisions through core's CreateScheme + PatchRole plugin API. - // It asserts the whole write→read chain end-to-end against real core: the custom set round-trips - // through GET (proving PatchRole set exactly those role permissions and they project back), a - // plain member is enforced against it, and switching back to a preset retires the now-unreferenced - // custom scheme. + // scenario8 exercises the POOLED scheme path — a default-capability set matching no preset — + // which the plugin provisions through core's CreateScheme + PatchRole plugin API. It asserts the + // whole write→read chain end-to-end against real core: the custom set round-trips through GET + // (proving PatchRole set exactly those role permissions and they project back), a plain member + // is enforced against it, and switching back to a preset repoints the channel, leaving the + // superseded scheme in place for any other space using it. t.Run("scenario8_custom_capability_scheme", func(t *testing.T) { customCaps := []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage} @@ -449,8 +449,8 @@ func TestScenarios(t *testing.T) { require.Equal(t, http.StatusCreated, status, "createSpace with custom default caps failed: %s", body) spacesToClean = append(spacesToClean, space.Id) - // The custom set must round-trip: create provisioned a docs_space_custom_* scheme whose user - // role PatchRole set to exactly {read_page}+customCaps, and GET projects that back. + // The custom set must round-trip: create resolved a pooled docs_space_default_* scheme whose + // user role PatchRole set to exactly {read_page}+customCaps, and GET projects that back. var withAccess pluginmodel.SpaceWithAccess status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) require.NoError(t, err) @@ -512,12 +512,77 @@ func TestScenarios(t *testing.T) { "OUTSIDER's default-granted write did not re-join them — removal should NOT be durable on an open space") }) - t.Run("gap_delete_page_not_grantable", func(t *testing.T) { - require.NotEmpty(t, s1ID, "scenario1 must have run first") - status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s1ID+"/members/"+contrib.id+"/capabilities", - map[string][]string{"granted_capabilities": {pluginmodel.CapabilityDeletePage}}, nil) + // delete_page (delete-any) is grantable to a plain member without making them a space admin, + // matching Confluence, where Delete is assignable independently of Admin. The grant reaches a + // page the grantee does not own — that is the whole difference from delete_own_page. + t.Run("delete_any_grantable_to_non_admin", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Delete Any"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Admin-owned Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + // Before the grant: the contribute default carries delete_own_page only, so a page the + // contributor does not own is out of reach. + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+seed.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "delete of an unowned page before the grant: %s", body) + + var granted pluginmodel.SpaceMember + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityDeletePage}}, &granted) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting delete_page: %s", body) + require.Contains(t, granted.GrantedCapabilities, pluginmodel.CapabilityDeletePage, + "delete_page did not round-trip through ExplicitRoles: %s", body) + require.False(t, granted.IsAdmin, "granting delete_page must not make the member a space admin") + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+seed.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "delete of an unowned page after the grant: %s", body) + }) + + // The same capability as a space default rather than a per-member grant, so every member holds + // delete-any. delete_page is in no preset, so this drives the pooled-scheme path end to end: + // the scheme is minted, the backing channel is attached to it, and only then does the role + // patch carrying delete_page become admissible to core. + t.Run("delete_any_as_space_default", func(t *testing.T) { + defaultCaps := []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityDeletePage} + + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Delete Any Default", "default_capabilities": defaultCaps}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace with a delete_page default failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + var withAccess pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "get space: %s", body) + require.ElementsMatch(t, defaultCaps, withAccess.DefaultCapabilities, + "delete_page did not round-trip as a space default: %s", body) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Admin-owned Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+seed.Id, nil, nil) require.NoError(t, err) - require.Equal(t, http.StatusBadRequest, status, "granting delete_page: %s", body) + require.Equal(t, http.StatusOK, status, "plain member delete of an unowned page: %s", body) }) t.Run("gap_anonymous_access_denied_on_open_space", func(t *testing.T) { diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index 2587730..d515629 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -26,8 +26,9 @@ var ( CapabilityCommentPage = mmmodel.PermissionCommentPage.Id CapabilityEditPage = mmmodel.PermissionEditPage.Id CapabilityDeleteOwnPage = mmmodel.PermissionDeleteOwnPage.Id - // CapabilityDeletePage (delete-any) is never independently grantable — it is granted only by - // the admin capability (SchemeAdmin), never by an ExplicitRoles atomic role. + // CapabilityDeletePage is delete-any: it covers pages the holder does not own, and so + // subsumes CapabilityDeleteOwnPage. Grantable independently of the admin capability, matching + // Confluence, where Delete is assignable without Admin. CapabilityDeletePage = mmmodel.PermissionDeletePage.Id // CapabilityAdminSpace is the admin capability: a member-grant target (toggles SchemeAdmin), // but never a valid space-default capability. @@ -35,22 +36,24 @@ var ( ) // grantableMemberCapabilities is the wire vocabulary a caller may explicitly grant to a member: -// the four atomic per-page capabilities plus the admin capability. +// the five atomic per-page capabilities plus the admin capability. var grantableMemberCapabilities = map[string]bool{ CapabilityCreatePage: true, CapabilityCommentPage: true, CapabilityEditPage: true, CapabilityDeleteOwnPage: true, + CapabilityDeletePage: true, CapabilityAdminSpace: true, } // grantableDefaultCapabilities is the wire vocabulary a space-default capability set may hold: -// the four atomic per-page capabilities. admin_space is member-grant-only, never a space default. +// the five atomic per-page capabilities. admin_space is member-grant-only, never a space default. var grantableDefaultCapabilities = map[string]bool{ CapabilityCreatePage: true, CapabilityCommentPage: true, CapabilityEditPage: true, CapabilityDeleteOwnPage: true, + CapabilityDeletePage: true, } // capabilityAtomicRole maps each non-admin grantable capability to the core atomic capability @@ -60,6 +63,7 @@ var capabilityAtomicRole = map[string]string{ CapabilityCommentPage: mmmodel.SpacePageCommenterRoleId, CapabilityEditPage: mmmodel.SpacePageEditorRoleId, CapabilityDeleteOwnPage: mmmodel.SpacePageDeleterOwnRoleId, + CapabilityDeletePage: mmmodel.SpacePageDeleterRoleId, } // atomicRoleCapability is the reverse of capabilityAtomicRole, used to parse a stored @@ -97,17 +101,14 @@ var presetCapabilitySets = map[string][]string{ mmmodel.SchemeNameSpaceReadOnly: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions)), } -// validateCapabilities validates capabilities against allowed, rejecting read_page as the non-grantable -// baseline and delete_page as admin-only, plus admin_space when rejectAdmin is set. An unknown -// token is rejected. Dedup-tolerant. where attributes the rejection to the calling validator. +// validateCapabilities validates capabilities against allowed, rejecting read_page as the +// non-grantable baseline, plus admin_space when rejectAdmin is set. An unknown token is rejected. +// Dedup-tolerant. where attributes the rejection to the calling validator. func validateCapabilities(where string, capabilities []string, allowed map[string]bool, rejectAdmin bool) *mmmodel.AppError { for _, c := range capabilities { if c == CapabilityReadPage { return mmmodel.NewAppError(where, "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) } - if c == CapabilityDeletePage { - return mmmodel.NewAppError(where, "model.space_capabilities.delete_page_not_grantable.app_error", nil, "", http.StatusBadRequest) - } if rejectAdmin && c == CapabilityAdminSpace { return mmmodel.NewAppError(where, "model.space_capabilities.admin_not_a_default.app_error", nil, "", http.StatusBadRequest) } @@ -211,7 +212,7 @@ func AdminEffectiveCapabilities() []string { return NormalizeCapabilitySet(spaceAdminEffectiveCapabilities) } -// DefaultCapabilitiesFromPermissions projects a custom scheme's stored user-role permission set +// DefaultCapabilitiesFromPermissions projects a pooled scheme's stored user-role permission set // (raw core permission ids) onto the wire default-capability vocabulary: read_page (the implicit // baseline) and any non-default-capability permission are stripped; only the grantable default // capability tokens survive. From b1f43de5b9ed2212653ee059455dc4c57a3a8c86 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 4 Aug 2026 15:24:15 +0200 Subject: [PATCH 22/28] address CC comments --- .github/workflows/ci.yml | 28 ++++++ assets/i18n/en.json | 8 ++ build/build-core-image.sh | 4 +- go.mod | 9 +- go.sum | 2 + server/api.go | 63 ++++++++------ server/api_handler_test.go | 14 ++- server/api_page.go | 34 ++++++-- server/api_page_drafts.go | 9 +- server/api_space.go | 11 ++- server/app/page_draft.go | 14 ++- server/app/permissions.go | 85 +++++++++++++++---- server/app/permissions_test.go | 57 ++++++++++++- server/app/space.go | 18 +++- server/app/space_members.go | 39 ++++----- server/app/space_test.go | 4 +- server/internal/testutil/permstub.go | 15 ++++ server/internal/testutil/schemestub.go | 40 +++++---- server/model/space_capabilities.go | 9 +- server/model/space_capabilities_test.go | 6 +- .../000007_add_viewaccess_to_spaces.up.sql | 4 +- 21 files changed, 359 insertions(+), 114 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edaff01..06b42fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,14 @@ jobs: # the CORE_IMAGE repository variable to that tag (or to a release tag once those changes ship). # While CORE_IMAGE is unset this job does not run: no available image could satisfy it, and a # silent pass would be worse than an absent one. + # + # DO NOT add this job to the repository's required status checks until CORE_IMAGE is set and the + # suite is observed passing. A job skipped by a top-level `if:` reports as "skipped", and branch + # protection counts a skipped required check as satisfied — so listing it early would admit every + # PR as though the suite had run, which is the outcome the condition above exists to avoid. + # For the same reason it is not in delivery/release `needs:` yet: a skipped dependency skips the + # dependent job. Add it to both once CORE_IMAGE is populated, so a permission regression blocks + # publishing. e2e: if: ${{ vars.CORE_IMAGE != '' && (github.repository_owner == 'mattermost' || github.event_name != 'schedule') }} runs-on: ubuntu-latest @@ -108,6 +116,9 @@ jobs: contents: read env: CORE_IMAGE: ${{ vars.CORE_IMAGE }} + # Lifted to env because the secrets context is not available in a step-level `if`, and the + # login below has to be skippable: the repository defines no Docker Hub credentials today. + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} steps: - name: Checkout repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -128,9 +139,26 @@ jobs: name: dist path: dist + # Authenticated so the core image pull is billed to the org's Docker Hub quota rather than the + # shared anonymous per-IP limit, which GitHub-hosted runners exhaust as a pull failure that + # reads like a test failure. Skipped when no credentials are configured: the pull still works + # anonymously, just against the lower limit, so a missing secret must not fail the suite. + - name: ci/docker-login + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: ci/e2e run: go test -tags e2e -count=1 -v ./server/e2e/... + # The suite boots the server in a container, so a boot or migration failure leaves its + # diagnosis in the container's log rather than in the test output. + - name: ci/e2e-logs + if: failure() + run: docker ps -aq | xargs -r -I{} sh -c 'echo "===== {} ====="; docker logs {} 2>&1 | tail -400' + delivery: if: ${{ github.repository_owner == 'mattermost' && github.event_name != 'schedule' && github.ref_name == 'master' }} runs-on: ubuntu-latest diff --git a/assets/i18n/en.json b/assets/i18n/en.json index f0ad0f4..edf65aa 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -391,6 +391,10 @@ "id": "app.space.access.forbidden.app_error", "translation": "You do not have permission to access this space." }, + { + "id": "app.space.access.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, { "id": "app.space.access.team_lookup_failed.app_error", "translation": "Failed to verify team membership." @@ -471,6 +475,10 @@ "id": "app.space.default_capabilities.scheme_configure_failed.app_error", "translation": "Failed to configure the space's new permission scheme." }, + { + "id": "app.space.default_capabilities.scheme_configure_rollback_failed.app_error", + "translation": "Failed to configure the space's new permission scheme, and the space could not be returned to its previous one. Contact your system administrator." + }, { "id": "app.space.delete.invalid_id.app_error", "translation": "Invalid space ID." diff --git a/build/build-core-image.sh b/build/build-core-image.sh index d58e439..4084b74 100755 --- a/build/build-core-image.sh +++ b/build/build-core-image.sh @@ -108,7 +108,9 @@ cp -R "$MM_SERVER_REPO/templates" "$MM_ROOT/templates" cp -R "$MM_SERVER_REPO/fonts" "$MM_ROOT/fonts" cat > "$BUILD_CTX/Dockerfile" <<'DOCKEREOF' -FROM ubuntu:noble +# Pinned by digest: this image is the server the e2e scenarios assert against, so an upstream +# repaint of the noble tag would change behaviour between two builds of identical source. +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ diff --git a/go.mod b/go.mod index f6e3c02..f4ea150 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/mattermost/mattermost/server/public v0.4.4-0.20260727152953-09b1991e5d5d + github.com/mattermost/mattermost/server/public v0.4.4-0.20260804125319-416829fe8ff3 github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 github.com/mattermost/morph v1.1.0 github.com/mattermost/squirrel v0.5.0 @@ -131,6 +131,7 @@ require ( modernc.org/sqlite v1.50.1 // indirect ) -// Local dev pin against the paired core branch while its scheme/role plugin-API additions are -// unmerged. Replace with a pseudo-version bump once those land upstream. -replace github.com/mattermost/mattermost/server/public => /Users/catalintomai/mattermost/MM-69269-core/server/public +// server/public is pinned to the head of the paired core PR (mattermost/mattermost#37685), which +// carries the scheme/role plugin-API additions this plugin calls. Repin to a released version once +// that PR merges. The go.sum entries for it were recorded directly from the module, since the +// checksum database has no record of a commit that is not yet on a released branch. diff --git a/go.sum b/go.sum index 1090279..dbf2d04 100644 --- a/go.sum +++ b/go.sum @@ -196,6 +196,8 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260804125319-416829fe8ff3 h1:9iwJaAj1UtabljjpAqz4VNb3W3TxjGNAlxRLSuUEjtc= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260804125319-416829fe8ff3/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= diff --git a/server/api.go b/server/api.go index 6579617..6db47a5 100644 --- a/server/api.go +++ b/server/api.go @@ -194,56 +194,71 @@ func (p *Plugin) requireSpaceDelete(w http.ResponseWriter, spaceID, userID strin // space it cannot read — then runs the auto-join pre-step when that read was admitted only via the // non-member open-space fall-through, then re-resolves perm as a (possibly just-joined) member. // Writes the error response and returns false on any denial. -func (p *Plugin) requirePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission) bool { - resolution, ok := p.requireSpaceReadFrom(w, "requirePageWrite", space, userID) - if !ok { - return false +// +// joined reports whether the pre-step created a membership; a handler whose write then fails must +// pass it to the service's UndoAutoJoin. +func (p *Plugin) requirePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission) (joined, ok bool) { + resolution, readOK := p.requireSpaceReadFrom(w, "requirePageWrite", space, userID) + if !readOK { + return false, false } return p.requirePageWriteFrom(w, space, userID, perm, resolution) } // requirePageWriteFrom is requirePageWrite for a caller that has already resolved the read gate for the // same space and user, so that resolution is not derived a second time. -func (p *Plugin) requirePageWriteFrom(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, resolution app.ReadResolution) bool { - if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, nil); appErr != nil { +func (p *Plugin) requirePageWriteFrom(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, resolution app.ReadResolution) (joined, ok bool) { + joined, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, nil) + if appErr != nil { p.writeAppError(w, appErr) - return false + return false, false } - if appErr := p.service.RequireSpacePagePermissionFrom("api.page.write", space, userID, perm, resolution); appErr != nil { - p.writeAppError(w, appErr) - return false + if permErr := p.service.RequireSpacePagePermissionFrom("api.page.write", space, userID, perm, resolution); permErr != nil { + // The gate itself rejected the caller, so the pre-step's membership is undone here rather + // than left for the handler, which never sees this path. + p.service.UndoAutoJoin(joined, space, userID) + p.writeAppError(w, permErr) + return false, false } - return true + return joined, true } // requireDeleteOwnOrAnyFrom gates a delete-class page operation: delete_page (any), or delete_own_page // when ownerID == userID. The auto-join pre-step runs against delete_own_page, gated on // ownership, since only that path can admit a non-member write. resolution is the read gate the // caller has already resolved for the same space and user, so it is not re-derived here. -func (p *Plugin) requireDeleteOwnOrAnyFrom(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) bool { - if _, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }); appErr != nil { +func (p *Plugin) requireDeleteOwnOrAnyFrom(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) (joined, ok bool) { + joined, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }) + if appErr != nil { p.writeAppError(w, appErr) - return false + return false, false } - _, ok := p.requireOwnOrAnyFrom(w, "requireDeleteOwnOrAnyFrom", space, userID, + if _, gateOK := p.requireOwnOrAnyFrom(w, "requireDeleteOwnOrAnyFrom", space, userID, "api.page.delete", mmmodel.PermissionDeletePage, - "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution) - return ok + "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution); !gateOK { + p.service.UndoAutoJoin(joined, space, userID) + return false, false + } + return joined, true } // requireDraftWrite gates a draft mutation on create-or-edit authority over the space, resolving // the read gate first like every other write. It returns the space so the caller can pass the // backing channel on to the service. -func (p *Plugin) requireDraftWrite(w http.ResponseWriter, spaceID, userID string) (*model.Space, app.ReadResolution, bool) { - space, resolution, ok := p.requireSpaceRead(w, spaceID, userID) - if !ok { - return nil, app.ReadDenied, false +// joined reports whether the auto-join pre-step created a membership; a handler whose draft write +// then fails must pass it to the service's UndoAutoJoin. +func (p *Plugin) requireDraftWrite(w http.ResponseWriter, spaceID, userID string) (space *model.Space, resolution app.ReadResolution, joined, ok bool) { + space, resolution, readOK := p.requireSpaceRead(w, spaceID, userID) + if !readOK { + return nil, app.ReadDenied, false, false } - if appErr := p.service.RequireSpaceDraftWrite("api.page_draft.write", space, userID, resolution); appErr != nil { + joined, appErr := p.service.RequireSpaceDraftWrite("api.page_draft.write", space, userID, resolution) + if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) - return nil, app.ReadDenied, false + return nil, app.ReadDenied, false, false } - return space, resolution, true + return space, resolution, joined, true } // requireOwnOrAnyFrom resolves a two-tier own/any permission pair against an already-resolved diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 9982e5d..0824332 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -1895,11 +1895,17 @@ func TestHandler_SetSpaceMemberCapabilities_Grant(t *testing.T) { grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) - // A plain (non-admin, non-guest) target whose ExplicitRoles reflects the granted atomic role, - // standing in for the post-UpdateChannelMemberRoles state: the mock does not track writes, so - // the projection is pinned by the specific role token the requested capability set maps to. + // The pre-update target: a plain member, no grant yet. This is the state the guest and + // last-admin guards read before the write. mockAPI.On("GetChannelMember", channelID, targetUserID). - Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, ExplicitRoles: mmmodel.SpacePageCreatorRoleId}, nil) + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID}, nil) + // The post-update target, echoing the roles just written — core returns the updated member from + // this call, and the response is projected from it. Registered before openTestPlugin so it wins + // over that helper's wildcard catch-all, which matching order would otherwise resolve first. + mockAPI.On("UpdateChannelMemberRoles", channelID, targetUserID, mock.Anything). + Return(func(chID, uID, newRoles string) (*mmmodel.ChannelMember, *mmmodel.AppError) { + return &mmmodel.ChannelMember{ChannelId: chID, UserId: uID, ExplicitRoles: newRoles}, nil + }) // Registered before openTestPlugin: the read-only preset must win over // StubDefaultChannelScheme's contribute-preset catch-all, and mock matching is by // registration order. diff --git a/server/api_page.go b/server/api_page.go index bced39e..d5384d5 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -45,7 +45,8 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.requirePageWrite(w, space, userID, mmmodel.PermissionCreatePage) { + joined, ok := p.requirePageWrite(w, space, userID, mmmodel.PermissionCreatePage) + if !ok { return } @@ -56,10 +57,12 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { Body string `json:"body,omitempty"` } if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleCreatePage", false) { + p.service.UndoAutoJoin(joined, space, userID) return } page, appErr := p.service.CreatePage(vars["space_id"], mmmodel.SafeDereference(req.ParentId), req.Title, req.Body, userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -90,7 +93,8 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) { + joined, ok := p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) + if !ok { return } @@ -103,12 +107,14 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { Force bool `json:"force"` } if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleUpdatePage", false) { + p.service.UndoAutoJoin(joined, space, userID) return } patch := &model.PagePatch{Title: req.Title, Body: req.Body, Props: req.Props} updated, appErr := p.service.UpdatePage(vars["page_id"], vars["space_id"], patch, req.BaseEditAt, req.Force, userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) if updated != nil { p.writeConflictWithPage(w, appErr, updated) return @@ -133,10 +139,12 @@ func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - if !p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) { + joined, ok := p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) + if !ok { return } if appErr := p.service.DeletePage(vars["page_id"], vars["space_id"], userID); appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -157,11 +165,13 @@ func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - if !p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) { + joined, ok := p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) + if !ok { return } restored, appErr := p.service.RestorePage(vars["page_id"], vars["space_id"], userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -180,7 +190,8 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { if !ok { return } - if !p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) { + joined, ok := p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) + if !ok { return } @@ -191,10 +202,12 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { Force bool `json:"force,omitempty"` } if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleMovePage", false) { + p.service.UndoAutoJoin(joined, space, userID) return } moved, appErr := p.service.MovePage(vars["page_id"], vars["space_id"], req.ParentId, req.SiblingIndex, req.ExpectedUpdateAt, req.Force, userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -232,12 +245,14 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { return } } - if !p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { + joined, ok := p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) + if !ok { return } duplicated, appErr := p.service.DuplicatePage(vars["page_id"], sourceSpace, userID, req.IncludeChildren, targetSpace, req.ParentId) if appErr != nil { + p.service.UndoAutoJoin(joined, targetSpace, userID) p.writeAppError(w, appErr) return } @@ -314,12 +329,17 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { requiredOwnerID = userID } - if !p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) { + joined, writeOK := p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) + if !writeOK { return } + // The subtree-ownership rule is enforced inside the move, so a caller admitted by the target + // gate can still be rejected here — including with a 403. Undoing the pre-step's membership + // extends the source-side ordering above to the rejections the gates cannot see. moved, appErr := p.service.MovePageToSpace(vars["page_id"], sourceSpace, targetSpace, req.ParentId, req.ExpectedUpdateAt, req.Force, userID, requiredOwnerID) if appErr != nil { + p.service.UndoAutoJoin(joined, targetSpace, userID) p.writeAppError(w, appErr) return } diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index 3f68577..af509ad 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -43,7 +43,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { pageID := vars["page_id"] userID := userIDFromRequest(r) - space, _, ok := p.requireDraftWrite(w, spaceID, userID) + space, _, joined, ok := p.requireDraftWrite(w, spaceID, userID) if !ok { return } @@ -57,6 +57,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { BaseEditAt *int64 `json:"base_edit_at"` } if !p.decodeJSONBody(w, r, maxDraftBodyBytes, &req, "handleUpdatePageDraft", false) { + p.service.UndoAutoJoin(joined, space, userID) return } @@ -83,6 +84,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { // req.Props nil → preserve; non-nil → replace the whole map (an empty map clears all keys). saved, appErr := p.service.UpdatePageDraft(draft, req.ParentId, req.FileIds, req.Props, space.ChannelId) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -149,7 +151,8 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) if !ok { return } - if !p.requirePageWriteFrom(w, space, userID, mmmodel.PermissionCreatePage, resolution) { + joined, writeOK := p.requirePageWriteFrom(w, space, userID, mmmodel.PermissionCreatePage, resolution) + if !writeOK { return } @@ -158,11 +161,13 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) ParentId string `json:"parent_id"` } if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleCreateSpaceDraft", false) { + p.service.UndoAutoJoin(joined, space, userID) return } saved, appErr := p.service.CreateSpaceDraft(userID, spaceID, req.Title, req.ParentId) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } diff --git a/server/api_space.go b/server/api_space.go index 77f90ff..84a1db2 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -115,7 +115,16 @@ func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { p.writeAppError(w, appErr) return } - writeJSON(w, http.StatusOK, updated) + // Answered with the same SpaceWithAccess wrapper the create and single-read routes return. + // Returning a bare space here would flatten to a body a client cannot tell apart from the + // wrapper, so refreshing a cached record from this response would silently drop the capability + // fields the other two routes supplied. + wrapper, wrapErr := p.service.BuildSpaceWithAccess(updated, userID) + if wrapErr != nil { + p.writeAppError(w, wrapErr) + return + } + writeJSON(w, http.StatusOK, wrapper) } // handleDeleteSpace handles DELETE /api/v1/spaces/{space_id}. diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 3647cfc..e8d52ab 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -289,7 +289,7 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) // - appErr is a 409 edit conflict → page is the current server page (or nil if the re-read failed), // so the caller can surface a diff without a follow-up read; on every other error page is nil. -func (s *Service) PublishPageDraft(space *model.Space, userID, pageID string, force bool, admittedVia ReadResolution) (*model.Page, bool, *mmmodel.AppError) { +func (s *Service) PublishPageDraft(space *model.Space, userID, pageID string, force bool, admittedVia ReadResolution) (publishedPage *model.Page, wasCreated bool, appErr *mmmodel.AppError) { if !mmmodel.IsValidId(userID) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } @@ -322,9 +322,19 @@ func (s *Service) PublishPageDraft(space *model.Space, userID, pageID string, fo // above: publishing a new page needs create_page, publishing over a live one needs edit_page. // The draft write that produced this content was gated only on the looser create-or-edit pair, // so this check must precede the writes below. - if permErr := s.RequireSpacePublish("PublishPageDraft", space, userID, admittedVia, isNewPage); permErr != nil { + joined, permErr := s.RequireSpacePublish("PublishPageDraft", space, userID, admittedVia, isNewPage) + if permErr != nil { + s.UndoAutoJoin(joined, space, userID) return nil, false, permErr } + // The writes below can still reject the publish — an unpublished parent, a failed validation, a + // concurrent autosave — so the membership the pre-step created is undone on every one of those + // paths rather than only on the gate's own denial. + defer func() { + if appErr != nil { + s.UndoAutoJoin(joined, space, userID) + } + }() // 3. Parent guard (new-page path only): a new page's parent must be a published live page; a // draft-only or non-live parent returns 409. The edit path never reparents, so a stale ParentId diff --git a/server/app/permissions.go b/server/app/permissions.go index 04a5d24..8ff2fca 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -4,9 +4,11 @@ package app import ( + "errors" "net/http" mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" @@ -49,8 +51,8 @@ func (s *Service) hasOpenTeamFallthrough(userID, teamID string) bool { return s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadPublicChannel) && !s.isComplianceEnabled() } -// readResolutionFrom evaluates the read gate against space for userID, taking sysadmin and active -// pre-resolved so a caller that already ran requireActiveMemberGate does not derive them twice. +// readResolutionFrom evaluates the read gate against space for userID, given the caller's +// already-resolved sysadmin and active-team-membership status. func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, userID string) ReadResolution { if sysadmin { return ReadViaSysadmin @@ -90,6 +92,11 @@ func (s *Service) requireActiveMemberGate(where string, space *model.Space, user if appErr = s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { return false, false, appErr } + // A malformed user id is a caller fault, not a denial: it reports as a 400 so it stays + // distinguishable from the existence-hiding 403 every genuine denial returns. + if !mmmodel.IsValidId(userID) { + return false, false, mmmodel.NewAppError(where, "app.space.access.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } if space == nil { return false, false, existenceHidingForbidden(where) } @@ -236,12 +243,14 @@ func (s *Service) RequireSpaceAdminOrSysadmin(where string, space *model.Space, } // requirePageWriteFrom runs the auto-join pre-step for perm and then gates on it, the shared tail -// of every page-write authorization on an already-resolved read. -func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID string, perm *mmmodel.Permission, admittedVia ReadResolution) *mmmodel.AppError { - if _, appErr := s.AutoJoinIfDefaultGranted(space, userID, admittedVia, perm, nil); appErr != nil { - return appErr +// of every page-write authorization on an already-resolved read. joined reports whether the +// pre-step created a membership, so a caller whose guarded write then fails can undo it. +func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID string, perm *mmmodel.Permission, admittedVia ReadResolution) (joined bool, appErr *mmmodel.AppError) { + joined, appErr = s.AutoJoinIfDefaultGranted(space, userID, admittedVia, perm, nil) + if appErr != nil { + return false, appErr } - return s.RequireSpacePagePermissionFrom(where, space, userID, perm, admittedVia) + return joined, s.RequireSpacePagePermissionFrom(where, space, userID, perm, admittedVia) } // RequireSpaceDraftWrite gates a draft mutation on the caller holding either page-creation or @@ -250,25 +259,32 @@ func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID // content needs is enforced at publish (RequireSpacePublish), the point where the draft becomes // state other users can see. Checking the looser pair here also keeps autosave off the page-liveness // lookup that the precise choice would require. -func (s *Service) RequireSpaceDraftWrite(where string, space *model.Space, userID string, admittedVia ReadResolution) *mmmodel.AppError { - createErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia) +func (s *Service) RequireSpaceDraftWrite(where string, space *model.Space, userID string, admittedVia ReadResolution) (joined bool, appErr *mmmodel.AppError) { + createJoined, createErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia) if createErr == nil { - return nil + return createJoined, nil } // Only a denial falls through to the second attempt: a failure of the check itself must not be // retried into a 403, which would present a backend outage to the user as "not authorized". // Mirrors ResolveSpacePageOwnOrAny's handling of the same two-attempt shape. if createErr.StatusCode != http.StatusForbidden { - return createErr + return createJoined, createErr } - return s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionEditPage, admittedVia) + // Either attempt can have joined, so the two results are combined: the caller must be able to + // undo a membership the create_page attempt created even when the edit_page attempt is the one + // that admitted it. + editJoined, editErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionEditPage, admittedVia) + return createJoined || editJoined, editErr } // RequireSpacePublish gates publishing a draft on the permission its target actually needs: // create_page when the draft becomes a new page, edit_page when it updates a live one. isNewPage -// comes from the publish path's own classification of the target row, so the decision is made -// where that fact already exists rather than costing the handler a second lookup. -func (s *Service) RequireSpacePublish(where string, space *model.Space, userID string, admittedVia ReadResolution, isNewPage bool) *mmmodel.AppError { +// comes from the publish path's own classification of the target row (a fresh draft versus one +// updating a live page). +// +// joined reports whether the auto-join pre-step created a membership; a caller whose publish then +// fails must pass it to UndoAutoJoin. +func (s *Service) RequireSpacePublish(where string, space *model.Space, userID string, admittedVia ReadResolution, isNewPage bool) (joined bool, appErr *mmmodel.AppError) { perm := mmmodel.PermissionEditPage if isNewPage { perm = mmmodel.PermissionCreatePage @@ -356,6 +372,11 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad } member, addErr := s.client.Channel.AddMember(fresh.ChannelId, userID) if addErr != nil { + // A vanished user is a caller-state condition, not a server fault; AddSpaceMember + // classifies the same failure the same way. + if errors.Is(addErr, pluginapi.ErrNotFound) { + return mmmodel.NewAppError("AutoJoinIfDefaultGranted", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(addErr) + } return addErr } joined = true @@ -365,8 +386,8 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad if lockErr != nil { return false, membershipLockAppError("AutoJoinIfDefaultGranted", lockErr) } - // Published after the lock is released: the membership lock holds a dedicated connection, so a - // slow publish inside it would push concurrent membership mutations into a lock timeout. + // Published after the lock is released, since the membership lock holds a dedicated connection + // for its whole duration. if joined { payload := map[string]any{"space_id": space.Id, "user_id": joinedUserID} s.publishToChannels(wsEventSpaceMemberAdded, payload, joinedChannelID) @@ -376,3 +397,33 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad } return joined, nil } + +// UndoAutoJoin removes a membership AutoJoinIfDefaultGranted created for a write that then failed, +// so a rejected request leaves no membership behind. Callers pass the joined result of the gate +// that admitted them; when it is false this does nothing. +// +// The pre-step runs ahead of the guarded write, so without this a request rejected after the gate — +// a cycle or depth breach on a move, a stale optimistic-lock baseline, or the subtree-ownership 403 — +// would still leave the caller a member of a space it never successfully wrote to. +// +// Removal is best-effort and reported only in the log: the caller is already returning the write's +// own error, and replacing it with a cleanup failure would hide why the request was rejected. A +// leftover membership grants no more than the space defaults already granted the caller. +func (s *Service) UndoAutoJoin(joined bool, space *model.Space, userID string) { + if !joined || space == nil || s.client == nil { + return + } + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + return s.client.Channel.DeleteMember(space.ChannelId, userID) + }) + if lockErr != nil { + s.log.Error("failed to remove the membership an auto-join created for a rejected write; the user remains a member of the space", + "space_id", space.Id, "user_id", userID, "err", lockErr) + return + } + payload := map[string]any{"space_id": space.Id, "user_id": userID} + s.publishToChannels(wsEventSpaceMemberRemoved, payload, space.ChannelId) + // The user has already left the backing channel, so the channel-scoped broadcast above never + // reaches them; send the event to their own connections directly. + s.publishToUser(wsEventSpaceMemberRemoved, payload, userID) +} diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go index 4638baf..732de4b 100644 --- a/server/app/permissions_test.go +++ b/server/app/permissions_test.go @@ -210,7 +210,7 @@ func TestRequireSpaceDraftWrite_LookupFailureIsNotADenial(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) space := testutil.MustCreateSpace(t, h.store, channelID, mmmodel.NewId()) - appErr := h.svc.RequireSpaceDraftWrite("test", space, userID, app.ReadViaOpenFallthrough) + _, appErr := h.svc.RequireSpaceDraftWrite("test", space, userID, app.ReadViaOpenFallthrough) require.NotNil(t, appErr) require.NotEqual(t, http.StatusForbidden, appErr.StatusCode, @@ -219,3 +219,58 @@ func TestRequireSpaceDraftWrite_LookupFailureIsNotADenial(t *testing.T) { // the edit_page retry. Each attempt runs the pre-step, which resolves team membership once. mockAPI.AssertNumberOfCalls(t, "GetTeamMember", 1) } + +// TestRequireSpacePagePermissionFrom_FallthroughAdmitsReadOnly is the escalation guard on the +// open-space fall-through: it exists to admit reads, and must never admit a write. A non-member +// whose space default withholds the write permission — so the auto-join pre-step does not join +// them — reaches the gate still holding only the fall-through, and every write permission has to +// be refused there. Without the read-permission condition on that branch the fall-through would +// grant create/edit/delete outright, since the caller already holds the team read_public_channel +// the branch keys on. +func TestRequireSpacePagePermissionFrom_FallthroughAdmitsReadOnly(t *testing.T) { + for _, perm := range []*mmmodel.Permission{ + mmmodel.PermissionCreatePage, mmmodel.PermissionEditPage, + mmmodel.PermissionDeletePage, mmmodel.PermissionDeleteOwnPage, + } { + t.Run(perm.Id, func(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, mmmodel.PermissionDeletePage).Return(false).Maybe() + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + appErr := h.svc.RequireSpacePagePermissionFrom("test", space, userID, perm, app.ReadViaOpenFallthrough) + + require.NotNil(t, appErr, "%s must not be admitted by the open-space read fall-through", perm.Id) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + }) + } +} + +// TestRequireSpacePagePermissionFrom_FallthroughAdmitsRead is the positive half of the pair above: +// the very same non-member and resolution do get read_page, so the refusals there come from the +// permission being a write, not from the fall-through being closed altogether. +func TestRequireSpacePagePermissionFrom_FallthroughAdmitsRead(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + appErr := h.svc.RequireSpacePagePermissionFrom("test", space, userID, mmmodel.PermissionReadPage, app.ReadViaOpenFallthrough) + + require.Nil(t, appErr) +} + +// TestResolveSpaceRead_InvalidUserIDIsBadRequest keeps a malformed user id reporting as a caller +// fault. Collapsing it into the existence-hiding 403 every genuine denial returns would make a +// plumbing bug indistinguishable from an ordinary authorization failure. +func TestResolveSpaceRead_InvalidUserIDIsBadRequest(t *testing.T) { + mockAPI := &plugintest.API{} + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, "") + + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, app.ReadDenied, resolution) +} diff --git a/server/app/space.go b/server/app/space.go index 8c0de50..a98a01a 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -435,6 +435,9 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ return nil, appErr } + // Whether the scheme was actually repointed. Both no-op branches below leave it false, so a + // caller resubmitting the set the space already carries produces no space_updated broadcast. + var repointed bool lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { channel, chanErr := s.client.Channel.GetChannelOfType(space.ChannelId, mmmodel.ChannelTypeSpace) if chanErr != nil { @@ -475,6 +478,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ if updErr := s.client.Channel.Update(channel); updErr != nil { return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.repoint_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) } + repointed = true // The repoint above is what lets core admit role writes carrying space permissions, so a // pooled scheme's roles are written only now. A failure leaves the space on a scheme whose @@ -493,7 +497,13 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ channel.SchemeId = ¤tSchemeID if rollbackErr := s.client.Channel.Update(channel); rollbackErr != nil { s.log.Error("failed to restore the previous space scheme after a failed scheme configuration; the space is left on a scheme whose roles may be unconfigured", "channel_id", space.ChannelId, "scheme_id", targetSchemeID, "previous_scheme_id", currentSchemeID, "err", rollbackErr) + // Distinct from the plain configure failure below: the space is left on the new + // scheme with roles that may be unconfigured, which an operator has to resolve. + // Reporting both as one id makes that state indistinguishable from the recovered + // case in monitoring. + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.scheme_configure_rollback_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) } + repointed = false return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) } } @@ -512,7 +522,9 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ // success as an error; project the response from the requested set and the pre-update // space instead, still firing the WS event. s.log.Warn("SetSpaceDefaultCapabilities: post-commit re-read failed; responding from the requested set", "space_id", space.Id, "err", getErr) - s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": space.Id}, space.ChannelId) + if repointed { + s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": space.Id}, space.ChannelId) + } wrapper, buildErr := s.BuildSpaceWithAccess(space, actingUserID) if buildErr != nil { return nil, buildErr @@ -520,7 +532,9 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ wrapper.DefaultCapabilities = model.NormalizeCapabilitySet(capabilities) return wrapper, nil } - s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": fresh.Id}, fresh.ChannelId) + if repointed { + s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": fresh.Id}, fresh.ChannelId) + } return s.BuildSpaceWithAccess(fresh, actingUserID) } diff --git a/server/app/space_members.go b/server/app/space_members.go index db5b783..677b431 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -173,6 +173,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st var newRoles string var newSchemeAdmin bool var resolvedRoles *schemeRoles + var updatedMember *mmmodel.ChannelMember lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { var rolesErr error resolvedRoles, rolesErr = s.getSchemeRolesForChannel(space.ChannelId) @@ -215,10 +216,12 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st if newSchemeAdmin { roles = roles + " " + resolvedRoles.AdminRoleName } - if _, updErr := s.client.Channel.UpdateChannelMemberRoles(space.ChannelId, targetUserID, roles); updErr != nil { + member, updErr := s.client.Channel.UpdateChannelMemberRoles(space.ChannelId, targetUserID, roles) + if updErr != nil { appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.update_capabilities_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) return appErr } + updatedMember = member return nil }) if lockErr != nil { @@ -235,22 +238,10 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st if defErr != nil { return nil, storeAppError("SetSpaceMemberCapabilities", defErr) } - fresh, memErr := s.client.Channel.GetMember(space.ChannelId, targetUserID) - var result *model.SpaceMember - if memErr != nil { - // The role update already committed, so re-reporting this as a failure would misreport - // success as an error; project the response from the requested capability set instead - // (guests are rejected above, so schemeGuest is always false here), still firing the WS - // event. - s.log.Warn("SetSpaceMemberCapabilities: post-commit member re-read failed; responding from the requested set", "space_id", space.Id, "user_id", targetUserID, "err", memErr) - result = toSpaceMember(&mmmodel.ChannelMember{ - UserId: targetUserID, - ExplicitRoles: newRoles, - SchemeAdmin: newSchemeAdmin, - }, defaultCapabilities) - } else { - result = toSpaceMember(fresh, defaultCapabilities) - } + // The response is projected from the member the role update returned. Re-reading it would go to + // a replica, which on a lagging one still carries the pre-update roles and would report the + // caller's own committed change as not having taken effect. + result := toSpaceMember(updatedMember, defaultCapabilities) payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} // Delivered both ways deliberately: the channel-scoped broadcast covers observers, and the @@ -291,13 +282,13 @@ func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID str appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) return appErr } - // Non-member target: existence-hiding on a private space (matches the read resolver's - // convention), a plain 404 on an open space (existence is already public there). That - // split only applies to self-removal — a manage-gated caller removing someone else has - // already proven manage authority over this space, so there is nothing left to - // existence-hide behind; it always gets the plain 404, matching - // SetSpaceMemberCapabilities. - if userID != actingUserID || space.ViewAccess == model.ViewAccessOpen { + // Non-member target: a manage-gated caller removing someone else has already proven + // manage authority over this space, so there is nothing left to existence-hide behind + // and it gets a plain 404, matching SetSpaceMemberCapabilities. Self-removal returns the + // shared existence-hiding 403 whatever the space's ViewAccess — keying the code on + // ViewAccess would let a caller read a space's exposure setting off the status alone, + // the distinction the rest of the surface exists to deny. + if userID != actingUserID { appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(memErr) } else { appErr = existenceHidingForbidden("RemoveSpaceMember") diff --git a/server/app/space_test.go b/server/app/space_test.go index 917f4a5..7b97b16 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -553,7 +553,7 @@ func stubCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { userRole, adminRole, guestRole := "custom_user_role", "custom_admin_role", "custom_guest_role" mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ Id: schemeID, - Name: "docs_space_default_" + mmmodel.NewId(), + Name: model.SharedSchemeNamePrefix + mmmodel.NewId(), Scope: mmmodel.SchemeScopeChannel, DefaultChannelUserRole: userRole, DefaultChannelAdminRole: adminRole, @@ -695,7 +695,7 @@ func stubCustomSchemeCreateFailingPatch(t *testing.T, mockAPI *plugintest.API) s userRole, adminRole, guestRole := "unconfigurable_user_role", "unconfigurable_admin_role", "unconfigurable_guest_role" mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ Id: schemeID, - Name: "docs_space_default_" + mmmodel.NewId(), + Name: model.SharedSchemeNamePrefix + mmmodel.NewId(), Scope: mmmodel.SchemeScopeChannel, DefaultChannelUserRole: userRole, DefaultChannelAdminRole: adminRole, diff --git a/server/internal/testutil/permstub.go b/server/internal/testutil/permstub.go index ed6c001..f50cc7d 100644 --- a/server/internal/testutil/permstub.go +++ b/server/internal/testutil/permstub.go @@ -23,6 +23,10 @@ import ( // BEFORE calling the harness constructor that invokes this — mock.Mock matches expectations in // registration order, so the more specific, earlier-registered stub takes precedence over these // catch-alls. +// +// The team grants here are team_user's. A test whose actor is a guest must register +// StubGuestTeamDefaults for that user first, since core's team_guest holds neither create_space nor +// read_public_channel and the catch-all below would otherwise admit it to both. func StubDefaultSpacePermissions(mockAPI *plugintest.API) { for _, p := range []*mmmodel.Permission{ mmmodel.PermissionReadPage, mmmodel.PermissionCreatePage, mmmodel.PermissionCommentPage, @@ -43,3 +47,14 @@ func StubDefaultSpacePermissions(mockAPI *plugintest.API) { } mockAPI.On("HasPermissionTo", mock.Anything, mmmodel.PermissionManageSystem).Return(false).Maybe() } + +// StubGuestTeamDefaults narrows guestUserID's team grants to what core's team_guest actually holds: +// read_space, but neither create_space nor read_public_channel. Register it BEFORE +// StubDefaultSpacePermissions, whose catch-alls match any user and would otherwise grant a guest +// the read_public_channel that admits the open-space non-member read fall-through. +func StubGuestTeamDefaults(mockAPI *plugintest.API, guestUserID string) { + for _, p := range []*mmmodel.Permission{mmmodel.PermissionCreateSpace, mmmodel.PermissionReadPublicChannel} { + mockAPI.On("HasPermissionToTeam", guestUserID, mock.Anything, p).Return(false).Maybe() + } + mockAPI.On("HasPermissionToTeam", guestUserID, mock.Anything, mmmodel.PermissionReadSpace).Return(true).Maybe() +} diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go index bacf371..fc6a25e 100644 --- a/server/internal/testutil/schemestub.go +++ b/server/internal/testutil/schemestub.go @@ -14,6 +14,8 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest" + + "github.com/mattermost/mattermost-plugin-docs/server/model" ) // presetSchemeFixture is the fixed identity and role/permission shape stubbed for one of the three @@ -148,18 +150,13 @@ func roleByID(roleID string) (*mmmodel.Role, bool) { return role, ok } -// roleByNameOrCreate returns the shared *Role for roleName, minting an empty one on first ask. Used -// for roles a test never names directly because the pool generates them from a scheme name. -func roleByNameOrCreate(roleName string) *mmmodel.Role { +// registerRoleByID records role under its id so a PatchRole stub, which core keys by id, reaches +// the same object a GetRoleByName stub handed out. Ids are generated per role, so unlike role names +// they never collide across tests. +func registerRoleByID(role *mmmodel.Role) { roleRegistry.Lock() defer roleRegistry.Unlock() - if role, ok := roleRegistry.byName[roleName]; ok { - return role - } - role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName} - roleRegistry.byName[roleName] = role roleRegistry.byID[role.Id] = role - return role } // StubbedRoleName resolves a stubbed role's id back to its name, so a test asserting on PatchRole — @@ -172,11 +169,9 @@ func StubbedRoleName(roleID string) (string, bool) { return role.Name, true } -// pooledSchemeNamePrefix mirrors the plugin-internal prefix every shared default-capability scheme -// carries. Restated here rather than imported because the plugin's copy is unexported. -const pooledSchemeNamePrefix = "docs_space_default_" - -func isPooledSchemeName(name string) bool { return strings.HasPrefix(name, pooledSchemeNamePrefix) } +func isPooledSchemeName(name string) bool { + return strings.HasPrefix(name, model.SharedSchemeNamePrefix) +} // StubPooledSchemeMiss answers the shared-pool lookup with not-found for any pooled scheme name, so // a test exercising a non-preset default-capability set reaches the create path. Register it after @@ -238,10 +233,25 @@ func StubSchemePool(mockAPI *plugintest.API) { // stubPooledRoles answers GetRoleByName for any role generated for a pooled scheme, minting the // shared *Role on first read so a later PatchRole mutation is visible to a subsequent read. +// +// The name-keyed map is per-call rather than package-level: a pooled scheme's name is a digest of +// its capability set, so two tests asking for the same set generate the same role names, and a +// shared map would hand the second test the first one's already-patched role. func stubPooledRoles(mockAPI *plugintest.API) { + var mu sync.Mutex + byName := map[string]*mmmodel.Role{} + mockAPI.On("GetRoleByName", mock.MatchedBy(isPooledSchemeName)). Return(func(roleName string) (*mmmodel.Role, *mmmodel.AppError) { - return roleByNameOrCreate(roleName), nil + mu.Lock() + defer mu.Unlock() + if role, ok := byName[roleName]; ok { + return role, nil + } + role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName} + byName[roleName] = role + registerRoleByID(role) + return role, nil }).Maybe() } diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index d515629..b5017f1 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -238,9 +238,10 @@ func DefaultCapabilitiesForSchemeName(name string) ([]string, bool) { return slices.Clone(capabilities), true } -// sharedSchemeNamePrefix labels every scheme in the shared default-capability pool. The suffix is -// derived from the capability set, so one scheme serves every space configured that way. -const sharedSchemeNamePrefix = "docs_space_default_" +// SharedSchemeNamePrefix labels every scheme in the shared default-capability pool. The suffix is +// derived from the capability set, so one scheme serves every space configured that way. Exported +// so a caller matching pooled scheme names resolves the prefix from here rather than restating it. +const SharedSchemeNamePrefix = "docs_space_default_" // sharedSchemeDisplayNamePrefix opens the operator-facing name of a pooled scheme, which then lists // the capability set the scheme grants. @@ -254,7 +255,7 @@ const sharedSchemeDisplayNamePrefix = "Space defaults: " // always meant when the capability vocabulary grows. func SharedSchemeNameForCapabilities(capabilities []string) string { sum := sha256.Sum256([]byte(strings.Join(NormalizeCapabilitySet(capabilities), " "))) - return sharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:16] + return SharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:16] } // SharedSchemeDisplayNameForCapabilities returns the operator-facing name of the pooled scheme for diff --git a/server/model/space_capabilities_test.go b/server/model/space_capabilities_test.go index 41ac654..1a19e12 100644 --- a/server/model/space_capabilities_test.go +++ b/server/model/space_capabilities_test.go @@ -147,7 +147,8 @@ func TestValidateGrantedCapabilities(t *testing.T) { t.Run("every non-admin grantable capability accepted", func(t *testing.T) { require.Nil(t, model.ValidateGrantedCapabilities([]string{ - model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage, + model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, + model.CapabilityDeleteOwnPage, model.CapabilityDeletePage, })) }) } @@ -176,7 +177,8 @@ func TestValidateDefaultCapabilities(t *testing.T) { t.Run("every non-admin grantable capability accepted", func(t *testing.T) { require.Nil(t, model.ValidateDefaultCapabilities([]string{ - model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage, + model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, + model.CapabilityDeleteOwnPage, model.CapabilityDeletePage, })) }) } diff --git a/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql b/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql index 359043c..2c68aef 100644 --- a/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql +++ b/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql @@ -1,5 +1,5 @@ ALTER TABLE DOCS_Space ADD COLUMN IF NOT EXISTS ViewAccess VARCHAR(16) NOT NULL DEFAULT 'private'; --- The DDL default is 'private' (fail-closed): every plugin insert writes ViewAccess explicitly, --- so this default only protects a row the application somehow left unset. +-- The DDL default is 'private' (fail-closed), so a row written without ViewAccess lands on the more +-- restrictive access level. ALTER TABLE DOCS_Space ADD CONSTRAINT chk_docs_space_view_access CHECK (ViewAccess IN ('open', 'private')); From 961ba2da4521cf1fe1cfc9b0f82917a8edff033e Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 4 Aug 2026 16:20:47 +0200 Subject: [PATCH 23/28] address coderabbitai comments --- Makefile | 8 ++++- server/api_handler_test.go | 1 - server/app/ws_events_test.go | 40 +++++++------------------ server/e2e/README.md | 5 ++-- server/e2e/helpers_test.go | 7 ++--- server/e2e/scenarios_test.go | 16 +++++----- server/internal/testutil/schemestub.go | 9 +++--- server/model/space_capabilities_test.go | 3 +- 8 files changed, 38 insertions(+), 51 deletions(-) diff --git a/Makefile b/Makefile index e9a0839..957ed12 100644 --- a/Makefile +++ b/Makefile @@ -367,6 +367,9 @@ endif ## into it, and drives the seven Confluence permission scenarios plus their named parity gaps ## through the real HTTP API. Requires Docker. See server/e2e/README.md. ## +## A namespaced CORE_IMAGE (one containing a `/`) names a pullable image, so the local build is +## skipped and Testcontainers fetches it — the path the CI e2e job takes. +## ## The bundle must contain a linux-$(arch) plugin binary for the Docker container to load it — ## `make server` alone only builds for the host OS/arch when MM_SERVICESETTINGS_ENABLEDEVELOPER is ## set (a common local dev convenience), which produces a bundle unusable inside the container. So @@ -385,7 +388,10 @@ test-e2e: echo "No plugin bundle with a linux-$$goarch binary found — running 'make dist' (forcing an all-architecture build)..."; \ MM_SERVICESETTINGS_ENABLEDEVELOPER= $(MAKE) dist; \ fi - ./build/build-core-image.sh + @case "$${CORE_IMAGE:-}" in \ + */*) echo "CORE_IMAGE=$$CORE_IMAGE is namespaced — leaving it to Testcontainers to pull; skipping the local core-image build." ;; \ + *) ./build/build-core-image.sh ;; \ + esac $(GO) test -tags e2e -count=1 -v ./server/e2e/... ## Creates a coverage report for the server code. diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 0824332..2ed42e3 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -2256,7 +2256,6 @@ func TestHandler_SetSpaceDefaultCapabilities_CreatesPooledScheme(t *testing.T) { mockAPI.AssertNumberOfCalls(t, "PatchRole", 3) } -// TestHandler_SetSpaceDefaultCapabilities_SwitchBackToPresetRetiresCustomScheme verifies that // TestHandler_SetSpaceDefaultCapabilities_ReusesPooledScheme pins the property the shared pool // exists for: a capability set resolves to one scheme forever. Switching a space to a non-preset // set mints a pooled scheme, switching away to a preset leaves it in place, and switching back diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index 9096d6d..e497247 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -592,9 +592,10 @@ func TestServiceRestoreSpace_PublishesRestoredEvent(t *testing.T) { &mmmodel.WebsocketBroadcast{ChannelId: restored.ChannelId}) } -// TestServiceAddSpaceMember_PublishesMemberAddedEvent pins space_member_added on add: -// space/user payload, broadcast scoped to the backing channel (matching the membership-gated -// reads, like every other space and page event). +// TestServiceAddSpaceMember_PublishesMemberAddedEvent pins space_member_added on add: its +// space/user payload and its two delivery targets — the channel-scoped broadcast for observers +// (matching the membership-gated reads, like every other space and page event), and the direct +// publish that reaches the added user, whom the channel-scoped resolution may not yet include. func TestServiceAddSpaceMember_PublishesMemberAddedEvent(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) @@ -607,9 +608,11 @@ func TestServiceAddSpaceMember_PublishesMemberAddedEvent(t *testing.T) { member, appErr := h.svc.AddSpaceMember(space, newUserID) require.Nil(t, appErr) - mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", - map[string]any{"space_id": space.Id, "user_id": member.UserId}, + payload := map[string]any{"space_id": space.Id, "user_id": member.UserId} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, &mmmodel.WebsocketBroadcast{ChannelId: space.ChannelId}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, + &mmmodel.WebsocketBroadcast{UserId: member.UserId}) } // TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent pins space_member_removed on remove: @@ -639,28 +642,6 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { &mmmodel.WebsocketBroadcast{UserId: targetID}) } -// TestServiceAddSpaceMember_PublishesToChannelAndUser pins space_member_added's two delivery -// targets: the channel-scoped broadcast for observers, and the direct publish that reaches the -// added user, whom the channel-scoped resolution may not yet include. -func TestServiceAddSpaceMember_PublishesToChannelAndUser(t *testing.T) { - mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - space, _ := createSpaceForMemberTests(t, h, mockAPI) - targetUserID := mmmodel.NewId() - - mockAPI.On("AddChannelMember", space.ChannelId, targetUserID). - Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetUserID}, nil) - - _, appErr := h.svc.AddSpaceMember(space, targetUserID) - require.Nil(t, appErr) - - payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} - mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, - &mmmodel.WebsocketBroadcast{ChannelId: space.ChannelId}) - mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, - &mmmodel.WebsocketBroadcast{UserId: targetUserID}) -} - // TestServiceSetSpaceMemberCapabilities_PublishesToChannelAndUser pins // space_member_capabilities_updated's two delivery targets. The direct publish is what guarantees // the target learns its own capabilities changed, so a regression to channel-only delivery would @@ -671,8 +652,9 @@ func TestServiceSetSpaceMemberCapabilities_PublishesToChannelAndUser(t *testing. space, actingUserID := createSpaceForMemberTests(t, h, mockAPI) targetUserID := mmmodel.NewId() - // The acting user must clear the admin-affecting escalation guard. - mockAPI.On("HasPermissionToChannel", actingUserID, space.ChannelId, mmmodel.PermissionAdminSpace).Return(true) + // A non-admin capability granted to another non-admin member reaches neither the + // admin-affecting nor the self-targeted arm of the escalation guard, so no space-admin + // permission is read here. mockAPI.On("GetChannelMember", space.ChannelId, targetUserID). Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetUserID}, nil) diff --git a/server/e2e/README.md b/server/e2e/README.md index 70b15ea..78dcc3a 100644 --- a/server/e2e/README.md +++ b/server/e2e/README.md @@ -4,9 +4,8 @@ This is the **official** end-to-end suite for the docs plugin's space-permission (MM-69269). It is Go + [Testcontainers](https://golang.testcontainers.org/), and boots a real Mattermost server with the plugin installed — no mocks. -It ports the seven canonical Confluence permission scenarios (plus their named parity gaps) from -`scripts/smoke-scenarios.sh` to run as a proper Go test suite. The bash script stays too, for fast -local iteration; the two are intentionally kept in lockstep rather than merged into one. +It covers the seven canonical Confluence permission scenarios plus their named parity gaps, and is +the authoritative behavioral spec for them. ## Why a locally built core image diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go index dde551d..44ace50 100644 --- a/server/e2e/helpers_test.go +++ b/server/e2e/helpers_test.go @@ -78,8 +78,7 @@ type actor struct { client *mmmodel.Client4 } -// createActor creates a real user, adds it to teamID, and logs it in — mirroring -// smoke-scenarios.sh's create_and_join_team + login_actor. +// createActor creates a real user, adds it to teamID, and logs it in. func createActor(t *testing.T, ctx context.Context, env *testEnv, teamID, username string) actor { t.Helper() @@ -124,7 +123,7 @@ type spaceMemberJSON struct { } // spaceHasMember reports whether userID currently appears in spaceID's member list, as resolved -// by the space admin actor — mirroring smoke-scenarios.sh's space_has_member. +// by the space admin actor. func spaceHasMember(t *testing.T, ctx context.Context, admin actor, spaceID, userID string) bool { t.Helper() var resp spaceMembersResponse @@ -149,7 +148,7 @@ func deleteSpace(t *testing.T, ctx context.Context, admin actor, spaceID string) } // pageDocBody is a minimal valid page body (see model.Page.IsValid — only size-limited, not -// schema-validated), matching smoke-scenarios.sh's create_page_body/edit_page_body. +// schema-validated). const pageDocBody = `{"type":"doc","content":[]}` // createPageReq builds a minimal valid page-create body. diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index ff00f98..dabb19e 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -23,9 +23,8 @@ import ( // TestScenarios runs the seven canonical Confluence space-permission scenarios plus the named // parity gaps against a real Mattermost server (built from the paired core branch) with the -// plugin installed, via Testcontainers. scripts/smoke-scenarios.sh is the authoritative -// behavioral spec and runs the same scenarios as a bash suite; the two are kept in lockstep, not -// merged. +// plugin installed, via Testcontainers. This suite is the authoritative behavioral spec for those +// scenarios. func TestScenarios(t *testing.T) { env := getEnv(t) ctx := context.Background() @@ -87,13 +86,14 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, status, "existing member edit: %s", body) - // Headline assertion: a team non-member's first default-granted write auto-joins them. + // Headline assertion: a team member who is not yet a space member is auto-joined by their + // first default-granted write. status, body, err = doPluginRequest(ctx, outsider.client, http.MethodPost, "/spaces/"+s1ID+"/pages", createPageReq("Drive-by page"), nil) require.NoError(t, err) - require.Equal(t, http.StatusCreated, status, "non-member create: %s", body) + require.Equal(t, http.StatusCreated, status, "space non-member create: %s", body) require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), - "non-member's drive-by write did not auto-join them (real GetChannelMember/AddChannelMember round-trip)") + "space non-member's drive-by write did not auto-join them (real GetChannelMember/AddChannelMember round-trip)") }) t.Run("scenario2_knowledge_base", func(t *testing.T) { @@ -601,8 +601,8 @@ func TestScenarios(t *testing.T) { // capability exists to grant, deny, or probe). } -// reverseString reverses s, mirroring smoke-scenarios.sh's `echo "$ID" | rev` — used to derive a -// syntactically valid but nonexistent id from a real one. +// reverseString reverses s — used to derive a syntactically valid but nonexistent id from a real +// one. func reverseString(s string) string { r := []rune(s) slices.Reverse(r) diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go index fc6a25e..e7fbe20 100644 --- a/server/internal/testutil/schemestub.go +++ b/server/internal/testutil/schemestub.go @@ -328,16 +328,17 @@ func StubChannelScheme(mockAPI *plugintest.API, channelID string, channel *mmmod } // resolveChannelRoles mirrors core's GetSchemeRolesForChannel for a stubbed channel: the roles of -// whichever scheme the channel currently points at. A channel with no scheme, or one pointing at a -// scheme no test registered, resolves to empty role names rather than a plausible-looking default, -// so a test relying on an unregistered scheme fails visibly. +// whichever scheme the channel currently points at. A channel with no scheme resolves to empty role +// names, matching core. A channel pointing at a scheme no test registered resolves to not-found, so +// a test relying on an unregistered scheme fails visibly rather than reading empty role names as a +// successful lookup. func resolveChannelRoles(channel *mmmodel.Channel) (string, string, string, *mmmodel.AppError) { if channel == nil || channel.SchemeId == nil || *channel.SchemeId == "" { return "", "", "", nil } roles, ok := rolesForScheme(*channel.SchemeId) if !ok { - return "", "", "", nil + return "", "", "", mmmodel.NewAppError("resolveChannelRoles", "app.scheme.get.app_error", nil, "", http.StatusNotFound) } return roles.Guest, roles.User, roles.Admin, nil } diff --git a/server/model/space_capabilities_test.go b/server/model/space_capabilities_test.go index 1a19e12..b0fcf77 100644 --- a/server/model/space_capabilities_test.go +++ b/server/model/space_capabilities_test.go @@ -14,13 +14,14 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// grantableCapabilities is the full grant vocabulary (the four atomic per-page capabilities plus +// grantableCapabilities is the full grant vocabulary (the five atomic per-page capabilities plus // admin_space), used to enumerate every subset for the round-trip test below. var grantableCapabilities = []string{ model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage, + model.CapabilityDeletePage, model.CapabilityAdminSpace, } From e6278fbf3ed267beb0b1d9c555ff1944f70080d4 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 5 Aug 2026 05:32:15 +0200 Subject: [PATCH 24/28] further restructuring(1) --- .github/workflows/ci.yml | 11 +++ server/api.go | 26 ++++-- server/api_handler_test.go | 54 ++++++++++++ server/app/permissions.go | 54 +++++++++++- server/app/permissions_test.go | 55 ++++++++++++ server/app/scheme.go | 8 +- server/app/scheme_test.go | 82 ++++++++++++++++++ server/app/space.go | 113 ++++++++++++++++++++----- server/app/space_members.go | 28 ++++-- server/app/space_test.go | 47 ++++++++++ server/e2e/README.md | 12 +-- server/e2e/helpers_test.go | 10 ++- server/e2e/scenarios_test.go | 13 ++- server/internal/testutil/schemestub.go | 6 +- server/model/space_capabilities.go | 16 +++- 15 files changed, 472 insertions(+), 63 deletions(-) create mode 100644 server/app/scheme_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06b42fa..0f41499 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,17 @@ jobs: fetch-depth: 0 persist-credentials: false + # The pin is checked, not just documented: a floating tag (:master, :latest) would silently + # re-point this suite at whatever core published most recently, so an unrelated upstream + # change would surface here as a test failure with no corresponding commit in this repo. + - name: ci/verify-core-image-pin + run: | + if [[ ! "$CORE_IMAGE" =~ :([0-9a-f]{7,40}|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "CORE_IMAGE must be pinned to a commit sha or a release tag; got '$CORE_IMAGE'." >&2 + echo "Core CI publishes one image per commit as :<7-char-sha>." >&2 + exit 1 + fi + - name: ci/setup uses: mattermost/actions/plugin-ci/setup@0256d363493a1e6b9e361ea788c62a0a73bc00be with: diff --git a/server/api.go b/server/api.go index 6db47a5..329c7ac 100644 --- a/server/api.go +++ b/server/api.go @@ -50,6 +50,9 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/members", p.handleGetSpaceMembers).Methods(http.MethodGet) api.HandleFunc("/spaces/{space_id}/members", p.handleAddSpaceMember).Methods(http.MethodPost) api.HandleFunc("/spaces/{space_id}/members/{user_id}", p.handleRemoveSpaceMember).Methods(http.MethodDelete) + // Unlike PATCH /spaces/{space_id}, whose nil fields mean "leave unchanged", these two replace + // the capability set outright: the body carries the full set the target should end up with, and + // a token omitted from it is revoked. There is no add-one/remove-one form. api.HandleFunc("/spaces/{space_id}/members/{user_id}/capabilities", p.handleSetSpaceMemberCapabilities).Methods(http.MethodPatch) api.HandleFunc("/spaces/{space_id}/default-capabilities", p.handleSetSpaceDefaultCapabilities).Methods(http.MethodPatch) @@ -116,7 +119,7 @@ func (p *Plugin) getSpaceForGate(w http.ResponseWriter, spaceID string, includeD } if appErr != nil { if appErr.StatusCode == http.StatusNotFound { - p.writeAppError(w, mmmodel.NewAppError("getSpaceForGate", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(appErr)) + p.writeAppError(w, app.ExistenceHidingForbidden("getSpaceForGate").Wrap(appErr)) return nil, false } p.writeAppError(w, appErr) @@ -224,15 +227,24 @@ func (p *Plugin) requirePageWriteFrom(w http.ResponseWriter, space *model.Space, } // requireDeleteOwnOrAnyFrom gates a delete-class page operation: delete_page (any), or delete_own_page -// when ownerID == userID. The auto-join pre-step runs against delete_own_page, gated on -// ownership, since only that path can admit a non-member write. resolution is the read gate the -// caller has already resolved for the same space and user, so it is not re-derived here. +// when ownerID == userID. Both are grantable as space defaults, so the auto-join pre-step is +// attempted against each in turn — delete_page first, unconditionally, then delete_own_page gated on +// ownership. Attempting only the own-scoped one would deny a non-member the delete a space +// defaulting to delete_page entitles them to. resolution is the read gate the caller has already +// resolved for the same space and user, so it is not re-derived here. func (p *Plugin) requireDeleteOwnOrAnyFrom(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) (joined, ok bool) { - joined, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }) + joined, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeletePage, nil) if appErr != nil { p.writeAppError(w, appErr) return false, false } + if !joined { + joined, appErr = p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }) + if appErr != nil { + p.writeAppError(w, appErr) + return false, false + } + } if _, gateOK := p.requireOwnOrAnyFrom(w, "requireDeleteOwnOrAnyFrom", space, userID, "api.page.delete", mmmodel.PermissionDeletePage, "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution); !gateOK { @@ -272,7 +284,7 @@ func (p *Plugin) requireOwnOrAnyFrom(w http.ResponseWriter, where string, space return false, false } if !allowed { - p.writeAppError(w, mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + p.writeAppError(w, app.ExistenceHidingForbidden(where)) return false, false } return ownOnly, true @@ -288,7 +300,7 @@ func (p *Plugin) requireSpaceReadFrom(w http.ResponseWriter, where string, space return app.ReadDenied, false } if resolution == app.ReadDenied { - p.writeAppError(w, mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden)) + p.writeAppError(w, app.ExistenceHidingForbidden(where)) return app.ReadDenied, false } return resolution, true diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 2ed42e3..1974dc1 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -1647,6 +1647,60 @@ func TestHandler_DeletePage(t *testing.T) { require.Equal(t, http.StatusNotFound, rec.Code) } +// TestHandler_DeletePage_OtherOwnerForbidden verifies the negative half of the own/any pair: a +// contribute-default member holds only delete_own_page, so a delete request against a page owned +// by someone else must be forbidden rather than falling through to the delete_page (any) grant it +// does not hold. +func TestHandler_DeletePage_OtherOwnerForbidden(t *testing.T) { + h := openTestPlugin(t, nil) + user := mmmodel.NewId() + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, h.db, channelID) + // Owned by someone else, so ownerMatches is false and the delete_own_page grant cannot apply. + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) + require.Equal(t, http.StatusForbidden, rec.Code) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) + require.Equal(t, http.StatusOK, rec.Code, "the page must still exist since the delete was denied") +} + +// TestHandler_DeletePage_NonMemberAutoJoinsViaDeletePageDefault verifies requireDeleteOwnOrAnyFrom +// attempts the auto-join pre-step against delete_page (any) first, unconditionally: a non-member +// of an open space whose default capability set grants delete_page is auto-joined and can delete a +// page it does not own, rather than being denied because only the narrower delete_own_page +// attempt was tried. +func TestHandler_DeletePage_NonMemberAutoJoinsViaDeletePageDefault(t *testing.T) { + mockAPI := newEnabledMockAPI() + stranger := mmmodel.NewId() + channelID := mmmodel.NewId() + + // The stranger is not yet a member: read_page is withheld so the read gate admits them only + // through the open-space fall-through, which is what the auto-join pre-step keys on. delete_page + // reflects what their post-join role would grant, standing in for the space's default + // capability set — the same way TestAutoJoin_JoinsWhenDefaultGrants' RolesGrantPermission stub + // does. Registered before openTestPlugin: mock.Mock matches expectations in registration order, + // and StubDefaultSpacePermissions' catch-all would otherwise grant read_page first. + mockAPI.On("HasPermissionToChannel", stranger, channelID, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("HasPermissionToChannel", stranger, channelID, mmmodel.PermissionDeletePage).Return(true) + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionDeletePage.Id).Return(true) + // Not yet a member: the join path runs only when the membership probe misses. + mockAPI.On("GetChannelMember", channelID, stranger). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + mockAPI.On("AddChannelMember", channelID, stranger). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: stranger}, nil) + + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + // Owned by someone else: only the delete_page (any) grant, not delete_own_page, can admit this. + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, stranger, nil) + require.Equal(t, http.StatusOK, rec.Code) + mockAPI.AssertCalled(t, "AddChannelMember", channelID, stranger) +} + // TestHandler_GetSpacePagesHasMoreBoundary pins the has_more transition exactly at the page-size // boundary: a window equal to the result count reports has_more=false; one smaller reports true. func TestHandler_GetSpacePagesHasMoreBoundary(t *testing.T) { diff --git a/server/app/permissions.go b/server/app/permissions.go index 8ff2fca..346e75a 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -5,6 +5,7 @@ package app import ( "errors" + "fmt" "net/http" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -25,6 +26,23 @@ const ( ReadViaOpenFallthrough ) +// String names the resolution, so a log field or a failed test assertion reports how the read was +// admitted rather than the underlying integer. +func (r ReadResolution) String() string { + switch r { + case ReadDenied: + return "denied" + case ReadViaSysadmin: + return "sysadmin" + case ReadViaMember: + return "member" + case ReadViaOpenFallthrough: + return "open_fallthrough" + default: + return fmt.Sprintf("ReadResolution(%d)", int(r)) + } +} + // existenceHidingForbidden is the shared 403 every enforcement helper returns on a lookup miss // or a denied check, so a caller cannot distinguish "doesn't exist", "not a member", and "no // longer a member" by status code or message. @@ -32,6 +50,13 @@ func existenceHidingForbidden(where string) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden) } +// ExistenceHidingForbidden is existenceHidingForbidden for the API layer, whose own gate helpers +// have to deny in the same indistinguishable terms. Exported so that message stays defined in one +// place: a second literal spelling of it elsewhere could drift and make the denials tellable apart. +func ExistenceHidingForbidden(where string) *mmmodel.AppError { + return existenceHidingForbidden(where) +} + // isComplianceEnabled reports whether ComplianceSettings.Enable is set and true. A nil client (or // nil config) reports false, matching core's own SafeDereference default. func (s *Service) isComplianceEnabled() bool { @@ -296,6 +321,10 @@ func (s *Service) RequireSpacePublish(where string, space *model.Space, userID s // generated user role) grants perm to a plain member — the auto-join admission test. Channel // without a scheme (ErrNotFound) reports false, not an error. func (s *Service) DefaultRolesGrantPermission(space *model.Space, perm *mmmodel.Permission) (bool, error) { + // Checked before the lookup below, which reaches through the client itself. + if s.client == nil || space == nil { + return false, nil + } roles, err := s.getSchemeRolesForChannel(space.ChannelId) if err != nil { if store.IsErrNotFound(err) { @@ -303,9 +332,6 @@ func (s *Service) DefaultRolesGrantPermission(space *model.Space, perm *mmmodel. } return false, err } - if s.client == nil { - return false, nil - } return s.client.User.RolesGrantPermission([]string{roles.UserRoleName}, perm.Id), nil } @@ -330,10 +356,22 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad if admittedVia != ReadViaOpenFallthrough { return false, nil } + if space == nil { + return false, existenceHidingForbidden("AutoJoinIfDefaultGranted") + } if appErr := s.requireClient("AutoJoinIfDefaultGranted", "space_id", space.Id, "user_id", userID); appErr != nil { return false, appErr } + // A set of defaults that cannot grant perm can never admit this write, so the answer is settled + // before taking the space-keyed lock. The lock holds a dedicated connection and serializes every + // membership and scheme mutation on the space, so a caller looping writes it is not entitled to + // would otherwise contend with those mutations on every attempt. Only a clean negative + // short-circuits: a failed lookup falls through, leaving the in-lock check below authoritative. + if granted, grantErr := s.DefaultRolesGrantPermission(space, perm); grantErr == nil && !granted { + return false, nil + } + joined := false var joinedUserID, joinedChannelID string lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { @@ -367,9 +405,17 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad return nil } } - if _, memErr := s.client.Channel.GetMember(fresh.ChannelId, userID); memErr == nil { + _, memErr := s.client.Channel.GetMember(fresh.ChannelId, userID) + if memErr == nil { return nil // Already a member. } + // Only an absence means "not a member yet". A failed lookup must not fall through to the add + // below: core returns the existing membership unchanged for an add of a current member, so + // this would report a join that never happened, and the undo paired with it would then + // remove a membership the caller already held. + if !errors.Is(memErr, pluginapi.ErrNotFound) { + return mmmodel.NewAppError("AutoJoinIfDefaultGranted", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) + } member, addErr := s.client.Channel.AddMember(fresh.ChannelId, userID) if addErr != nil { // A vanished user is a caller-state condition, not a server fault; AddSpaceMember diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go index 732de4b..7ed57de 100644 --- a/server/app/permissions_test.go +++ b/server/app/permissions_test.go @@ -111,6 +111,10 @@ func TestAutoJoin_PrivateFlipAbortsJoin(t *testing.T) { stubNonMember(mockAPI, userID) h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + // Granted, so the re-validation below is the only thing that can abort the join — the pre-step's + // own admission test must not be what makes this pass. + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + // The caller still holds the open-space record it was admitted against; the stored row has // since flipped private, which is what the pre-step re-reads. stale := *space @@ -133,6 +137,9 @@ func TestAutoJoin_DeletedSpaceIsNoOp(t *testing.T) { stubNonMember(mockAPI, userID) h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + // Granted, so the deleted-space branch below is the only thing that can prevent the join. + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + require.NoError(t, h.store.DeleteSpace(space.Id)) joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) @@ -261,6 +268,54 @@ func TestRequireSpacePagePermissionFrom_FallthroughAdmitsRead(t *testing.T) { require.Nil(t, appErr) } +// TestRequireSpacePagePermission_FormerTeamMemberDenied verifies the same team-membership guard +// evaluatePagePermission applies as readResolutionFrom (see +// TestResolveSpaceRead_FormerTeamMemberDenied): a user who left the space's team is denied even +// though their backing-channel ChannelMember row still exists, because active is false and the +// `active &&` conjunct on the channel-permission check short-circuits before it is ever consulted. +func TestRequireSpacePagePermission_FormerTeamMemberDenied(t *testing.T) { + mockAPI := &plugintest.API{} + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + // Registered before the harness, whose GetTeamMember catch-all returns an active membership: + // mock.Mock matches expectations in registration order. + mockAPI.On("GetTeamMember", teamID, userID). + Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) + h := openTestServiceWithAPI(t, mockAPI) + + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + + appErr := h.svc.RequireSpacePagePermission("test", space, userID, mmmodel.PermissionReadPage) + require.NotNil(t, appErr) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + // The team gate blocks before any channel-scoped permission is consulted, so the lingering + // ChannelMember row is irrelevant. + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) +} + +// TestResolveSpaceRead_ComplianceModeSuppressesOpenFallthrough verifies that the open-space +// non-member read fall-through is suppressed under ComplianceSettings.Enable, even though the +// caller holds the team read_public_channel grant hasOpenTeamFallthrough otherwise admits on. +func TestResolveSpaceRead_ComplianceModeSuppressesOpenFallthrough(t *testing.T) { + mockAPI := &plugintest.API{} + strangerID := mmmodel.NewId() + // Registered before the harness so it takes precedence over StubDefaultSpacePermissions' + // permissive catch-all: mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionToChannel", strangerID, mock.Anything, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("GetConfig"). + Return(&mmmodel.Config{ComplianceSettings: mmmodel.ComplianceSettings{Enable: mmmodel.NewPointer(true)}}).Once() + h := openTestServiceWithAPI(t, mockAPI) + + // The default space fixture is open, and the harness stubs GetTeamMember to an active + // membership, so the stranger clears the team gate and reaches the open-space fall-through. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, strangerID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution, + "compliance mode must suppress the open-space non-member fall-through even though the caller holds read_public_channel") +} + // TestResolveSpaceRead_InvalidUserIDIsBadRequest keeps a malformed user id reporting as a caller // fault. Collapsing it into the existence-hiding 403 every genuine denial returns would make a // plumbing bug indistinguishable from an ordinary authorization failure. diff --git a/server/app/scheme.go b/server/app/scheme.go index 402e521..1952d84 100644 --- a/server/app/scheme.go +++ b/server/app/scheme.go @@ -114,6 +114,12 @@ func (s *Service) getRolePermissionsByName(roleName string) ([]string, error) { func (s *Service) getOrCreateSharedScheme(capabilities []string) (string, *schemeRoles, error) { name := model.SharedSchemeNameForCapabilities(capabilities) if scheme, err := s.client.Scheme.GetByName(name); err == nil { + // Logged because the configure that follows rewrites roles every space already on this + // scheme resolves against, not just this caller's space. The write is idempotent while the + // capability-to-permission mapping is correct, so a mapping regression is otherwise + // indistinguishable from ordinary traffic until its effects show up across those spaces. + s.log.Debug("resolved an existing pooled space scheme; its role write applies to every space sharing it", + "scheme_id", scheme.Id, "scheme_name", name) return scheme.Id, rolesFromScheme(scheme), nil } else if !errors.Is(err, pluginapi.ErrNotFound) { return "", nil, err @@ -149,7 +155,7 @@ func rolesFromScheme(scheme *mmmodel.Scheme) *schemeRoles { // roles are the names getOrCreateSharedScheme returned, so the writes land on the resolved scheme // rather than on whatever a channel currently points at. // -// It must run only once a space backing channel already points at that scheme: core admits a role +// It must run only once a space backing channel already points at that scheme: core allows a role // write carrying space permissions for a seeded preset's roles, or for a scheme a space backing // channel already references, and it does not accept a caller-chosen scheme name as proof. // Idempotent, so re-running it against an already-configured pooled scheme is a no-op in effect. diff --git a/server/app/scheme_test.go b/server/app/scheme_test.go new file mode 100644 index 0000000..06702a4 --- /dev/null +++ b/server/app/scheme_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// TestGetOrCreateSharedScheme_ConcurrentCreateAdoptsWinner covers the race guard: the pooled +// scheme's name is a pure function of the capability set, so two callers racing the same +// capability set collide on create. The loser must adopt the winner's scheme instead of failing +// the caller outright. +func TestGetOrCreateSharedScheme_ConcurrentCreateAdoptsWinner(t *testing.T) { + s, _ := testutil.OpenTestStore(t) + mockAPI := &plugintest.API{} + capabilities := []string{"create_page"} + name := model.SharedSchemeNameForCapabilities(capabilities) + + winner := &mmmodel.Scheme{ + Id: mmmodel.NewId(), + Name: name, + DefaultChannelUserRole: "winner_user_role", + DefaultChannelAdminRole: "winner_admin_role", + DefaultChannelGuestRole: "winner_guest_role", + } + + // The first lookup misses: this caller has not seen the scheme yet. + mockAPI.On("GetSchemeByName", name). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: 404}).Once() + // This caller's own create loses the race. + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{Id: "app.scheme.create.app_error", StatusCode: 500}).Once() + // The second lookup, taken after the failed create, finds the concurrent winner. + mockAPI.On("GetSchemeByName", name).Return(winner, nil).Once() + + client := pluginapi.NewClient(mockAPI, nil) + svc := New(s, nil, client) + + schemeID, roles, err := svc.getOrCreateSharedScheme(capabilities) + require.NoError(t, err, "a create lost to a concurrent winner must not fail the caller") + require.Equal(t, winner.Id, schemeID) + require.NotNil(t, roles) + require.Equal(t, "winner_user_role", roles.UserRoleName) + require.Equal(t, "winner_admin_role", roles.AdminRoleName) + require.Equal(t, "winner_guest_role", roles.GuestRoleName) + mockAPI.AssertExpectations(t) +} + +// TestSchemeRolesFromChannel_MissingSchemeVariants covers both shapes of "no scheme" the code +// treats as distinct cases: a nil SchemeId and a non-nil SchemeId pointing at an empty string. +// Either must resolve to not-found rather than falling through to a scheme lookup. +func TestSchemeRolesFromChannel_MissingSchemeVariants(t *testing.T) { + s, _ := testutil.OpenTestStore(t) + svc := New(s, nil, nil) + + t.Run("nil SchemeId", func(t *testing.T) { + channel := &mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace} + _, err := svc.schemeRolesFromChannel(channel.Id, channel) + require.Error(t, err) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("empty string SchemeId", func(t *testing.T) { + empty := "" + channel := &mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace, SchemeId: &empty} + _, err := svc.schemeRolesFromChannel(channel.Id, channel) + require.Error(t, err) + require.True(t, store.IsErrNotFound(err)) + }) +} diff --git a/server/app/space.go b/server/app/space.go index a98a01a..9d2f061 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -113,10 +113,42 @@ func (s *Service) hasOtherAuthorizedMemberMatching(space *model.Space, excludeUs return found, nil } -// hasOtherAuthorizedMember reports whether any backing-channel member other than excludeUserID can -// still reach the space. -func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID string) (bool, error) { - return s.hasOtherAuthorizedMemberMatching(space, excludeUserID, func(*mmmodel.ChannelMember) bool { return true }) +// otherAuthorizedMembers answers both reachability questions the removal guards ask — is there +// another member who can still reach the space, and is one of them an admin — in a single walk. +// The admin set is a subset of the reachable set, so a caller needing both would otherwise pay two +// full walks, each with its own per-member team lookup, while holding the space membership lock. +// Iteration stops once both answers are known, and a row that cannot change either answer is +// skipped before its team lookup. +func (s *Service) otherAuthorizedMembers(space *model.Space, excludeUserID string) (anyMember, anyAdmin bool, err error) { + err = s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { + if cm.UserId == excludeUserID { + return false, nil + } + // Once a reachable member is known, only an admin row can still teach us anything. + if anyMember && !cm.SchemeAdmin { + return false, nil + } + reachable := space.TeamId == "" + if !reachable { + active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) + if activeErr != nil { + return false, activeErr + } + reachable = active + } + if !reachable { + return false, nil + } + anyMember = true + if cm.SchemeAdmin { + anyAdmin = true + } + return anyMember && anyAdmin, nil + }) + if err != nil { + return false, false, err + } + return anyMember, anyAdmin, nil } // archiveOrphanChannel archives a backing channel when a later step in space creation fails, @@ -376,6 +408,16 @@ func (s *Service) defaultCapabilitiesForRoles(roles *schemeRoles) ([]string, err // capability set plus the caller's own truthful, current effective capabilities — never a // hypothetical post-join grant. A denied read yields the shared existence-hiding 403. func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*model.SpaceWithAccess, *mmmodel.AppError) { + return s.buildSpaceWithAccess(space, userID, nil) +} + +// buildSpaceWithAccess is BuildSpaceWithAccess for a caller that already knows the space's default +// capability set. knownDefaults, when non-nil, is used instead of reading the scheme's roles back: +// a caller that just wrote those roles must not re-read them, since the read may be served by a +// lagging replica still carrying the pre-write set and would report the caller's own committed +// change as not having taken effect. The caller's effective capabilities are derived from the same +// value, so both fields describe one consistent state. +func (s *Service) buildSpaceWithAccess(space *model.Space, userID string, knownDefaults []string) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("BuildSpaceWithAccess", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } @@ -391,9 +433,13 @@ func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*mode if resolution == ReadDenied { return nil, existenceHidingForbidden("BuildSpaceWithAccess") } - defaultCapabilities, err := s.spaceDefaultCapabilities(space) - if err != nil { - return nil, storeAppError("BuildSpaceWithAccess", err) + defaultCapabilities := knownDefaults + if defaultCapabilities == nil { + var err error + defaultCapabilities, err = s.spaceDefaultCapabilities(space) + if err != nil { + return nil, storeAppError("BuildSpaceWithAccess", err) + } } var capabilities []string @@ -438,6 +484,9 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ // Whether the scheme was actually repointed. Both no-op branches below leave it false, so a // caller resubmitting the set the space already carries produces no space_updated broadcast. var repointed bool + // The normalized set the space carries once this returns, carried out of the closure so the + // response is projected from it rather than re-read from the roles just written. + var requested []string lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { channel, chanErr := s.client.Channel.GetChannelOfType(space.ChannelId, mmmodel.ChannelTypeSpace) if chanErr != nil { @@ -452,7 +501,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ // the no-op shortcut, so it is carried as a value rather than failing the whole operation. liveCapabilities, liveCapabilitiesErr := s.spaceDefaultCapabilitiesFromChannel(space.ChannelId, channel) - requested := model.NormalizeCapabilitySet(capabilities) + requested = model.NormalizeCapabilitySet(capabilities) _, requestedIsPreset := model.SchemeNameForDefaultCapabilities(requested) // An unchanged non-preset set is settled here, on the projected capabilities, so the no-op @@ -516,6 +565,11 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ return nil, membershipLockAppError("SetSpaceDefaultCapabilities", lockErr) } + // The response is projected from the set written under the lock, not read back from the roles + // this call just wrote: those reads go through core, which serves them from a replica that on a + // lagging one still carries the pre-update roles and would report the caller's own committed + // change as not having taken effect. Matches SetSpaceMemberCapabilities, which projects from the + // member its role update returned for the same reason. fresh, getErr := s.GetSpace(space.Id) if getErr != nil { // The scheme repoint already committed, so re-reporting this as a failure would misreport @@ -525,17 +579,12 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ if repointed { s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": space.Id}, space.ChannelId) } - wrapper, buildErr := s.BuildSpaceWithAccess(space, actingUserID) - if buildErr != nil { - return nil, buildErr - } - wrapper.DefaultCapabilities = model.NormalizeCapabilitySet(capabilities) - return wrapper, nil + return s.buildSpaceWithAccess(space, actingUserID, requested) } if repointed { s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": fresh.Id}, fresh.ChannelId) } - return s.BuildSpaceWithAccess(fresh, actingUserID) + return s.buildSpaceWithAccess(fresh, actingUserID, requested) } // GetSpaceMembers, AddSpaceMember, SetSpaceMemberCapabilities, and RemoveSpaceMember live in @@ -636,6 +685,12 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec s.log.Debug("Updating space", "space_id", space.Id) + // A private->private no-op (the patch re-asserts the current value) must not attach + // member_count: only a genuine open->private transition sheds no members but exposes an + // admin to a count they should act on. Compare against the live row's prior value, not + // just the patch value. Carried out of apply so the count lookup and the broadcast it feeds + // run after the lock is released. + var flippedToPrivate bool apply := func() (*model.Space, *mmmodel.AppError) { var live *model.Space if patch.ViewAccess != nil { @@ -654,12 +709,11 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec if err != nil { return nil, storeAppError("UpdateSpace", err) } - // A private->private no-op (the patch re-asserts the current value) must not attach - // member_count: only a genuine open->private transition sheds no members but exposes an - // admin to a count they should act on. Compare against the live row's prior value, not - // just the patch value. - flippedToPrivate := patch.ViewAccess != nil && *patch.ViewAccess == model.ViewAccessPrivate && + flippedToPrivate = patch.ViewAccess != nil && *patch.ViewAccess == model.ViewAccessPrivate && live != nil && live.ViewAccess == model.ViewAccessOpen + // Stays under the lock: it projects the row this call just wrote onto the backing channel, + // so two concurrent updates must not interleave their syncs and leave the channel carrying + // the older title. if updated.ChannelId != "" && s.client != nil { if chanErr := s.syncSpaceChannelMetadata(updated.Id); chanErr != nil { // Deliberately not returned: the space row (the source of truth) committed, so @@ -670,8 +724,16 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec s.log.Error("UpdateSpace: failed to sync backing channel metadata; display name/header stale until the next update", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", chanErr) } } + return updated, nil + } + + // publish reports the update once the space-keyed lock is no longer held. The lock holds a + // dedicated connection for the whole closure, so the member-count lookup and the broadcast — + // neither of which the update's atomicity depends on — are kept out of it, matching every other + // membership-lock caller in this package. + publish := func(updated *model.Space) { payload := map[string]any{"space_id": updated.Id} - if flippedToPrivate { + if flippedToPrivate && s.client != nil { // Privatizing does not shed members: every existing member (including anyone // auto-joined earlier) stays. Surface the current count so the admin is prompted to // prune via RemoveSpaceMember. @@ -686,11 +748,15 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec } } s.publishToChannels(wsEventSpaceUpdated, payload, updated.ChannelId) - return updated, nil } if space.ChannelId == "" { - return apply() + updated, appErr := apply() + if appErr != nil { + return nil, appErr + } + publish(updated) + return updated, nil } var result *model.Space lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { @@ -704,6 +770,7 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec if lockErr != nil { return nil, membershipLockAppError("UpdateSpace", lockErr) } + publish(result) return result, nil } diff --git a/server/app/space_members.go b/server/app/space_members.go index 677b431..2d74dcf 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -117,9 +117,10 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac } // hasOtherAuthorizedAdmin reports whether space's backing channel still has a SchemeAdmin member -// who can reach the space once excludeUserID is disregarded — the last-admin invariant's -// admin-side counterpart to hasOtherAuthorizedMember. excludeUserID, when non-empty, is skipped, so -// the answer describes what would remain after that user is demoted or removed. +// who can reach the space once excludeUserID is disregarded. excludeUserID, when non-empty, is +// skipped, so the answer describes what would remain after that user is demoted or removed. A +// caller that also needs the any-member answer should use otherAuthorizedMembers, which resolves +// both in one walk. func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID string) (bool, error) { return s.hasOtherAuthorizedMemberMatching(space, excludeUserID, func(cm *mmmodel.ChannelMember) bool { return cm.SchemeAdmin }) } @@ -296,19 +297,28 @@ func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID str return appErr } + // Resolved before the scan below so an unauthorized caller is rejected without paying for it. if target.SchemeAdmin { if e := s.RequireSpaceAdminOrSysadmin("RemoveSpaceMember", space, actingUserID); e != nil { appErr = e return e } - if e := s.requireNotLastAdmin("RemoveSpaceMember", space, userID); e != nil { - appErr = e - return e - } } - hasOther, guardErr := s.hasOtherAuthorizedMember(space, userID) + // The last-admin and last-member invariants are answered from one walk: removing an admin + // needs both, and the admin set is a subset of the reachable set. + hasOther, hasOtherAdmin, guardErr := s.otherAuthorizedMembers(space, userID) if guardErr != nil { - appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + // Attributed to whichever invariant the caller is actually being held to, so the failure + // of the shared walk reports the same id each guard reported when it walked alone. + if target.SchemeAdmin { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + } else { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + } + return appErr + } + if target.SchemeAdmin && !hasOtherAdmin { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) return appErr } if !hasOther { diff --git a/server/app/space_test.go b/server/app/space_test.go index 7b97b16..5e3f05a 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -774,6 +774,53 @@ func TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack(t *testing mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) } +// TestServiceSetSpaceDefaultCapabilities_ResponseReflectsRequestedNotStaleReadback covers the +// projection guard: the response is built from the capability set written under the lock, not +// from a fresh read of the roles that write just committed. GetRoleByName here always answers with +// a role frozen on a pre-update permission set, standing in for a lagging-replica read of the +// caller's own committed change; the response must still report the requested set. +func TestServiceSetSpaceDefaultCapabilities_ResponseReflectsRequestedNotStaleReadback(t *testing.T) { + mockAPI := &plugintest.API{} + sysadminID := mmmodel.NewId() + // Registered before the harness so the response's own-capability projection resolves via + // sysadmin (AdminEffectiveCapabilities), sidestepping the ReadViaMember channel-member lookup + // this test does not otherwise stub. mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionTo", sysadminID, mmmodel.PermissionManageSystem).Return(true) + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + + testutil.StubPooledSchemeMiss(mockAPI) + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "stale_readback_user_role", "stale_readback_admin_role", "stale_readback_guest_role" + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: model.SharedSchemeNamePrefix + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + // Frozen on a permission set that does not match the request below, and never updated by the + // PatchRole stub: a caller that re-read this role after the write would see the wrong set. + mockAPI.On("GetRoleByName", userRole). + Return(&mmmodel.Role{Id: mmmodel.NewId(), Name: userRole, Permissions: []string{model.CapabilityCommentPage}}, nil) + mockAPI.On("GetRoleByName", adminRole).Return(&mmmodel.Role{Id: mmmodel.NewId(), Name: adminRole}, nil) + mockAPI.On("GetRoleByName", guestRole).Return(&mmmodel.Role{Id: mmmodel.NewId(), Name: guestRole}, nil) + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). + Return(&mmmodel.Role{}, nil) + + space := mustCreateSpace(t, h.store, h.db, channelID) + + updated, appErr := h.svc.SetSpaceDefaultCapabilities(space, []string{"create_page"}, sysadminID) + require.Nil(t, appErr) + require.NotNil(t, updated) + require.ElementsMatch(t, []string{"create_page"}, updated.DefaultCapabilities, + "the response must report the requested set, not a stale role read-back") +} + // TestServiceRestoreSpace_UnarchivesBackingChannel verifies a create→delete→restore round trip // un-archives the backing channel and brings the space back live. func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { diff --git a/server/e2e/README.md b/server/e2e/README.md index 78dcc3a..1535ddd 100644 --- a/server/e2e/README.md +++ b/server/e2e/README.md @@ -47,8 +47,10 @@ all tests finish. Every scenario creates its own space and it is deleted at the Scenario 6 (read-only guest reviewer) drives the real `POST /users/{id}/demote` core endpoint. That endpoint requires an Enterprise license (`api.team.demote_user_to_guest.license.error`), and -the core image built by `build/build-core-image.sh` has none. In that case the subtest logs the -gap via `t.Log` and returns without asserting the guest-specific behavior — it does not fail the -suite, and it does not fake a pass. Supplying a real EE license to the container (extend -`startEnv` in `container_test.go` with `mmcontainer.WithLicense`) would let this subtest run to -completion. +the core image built by `build/build-core-image.sh` has none. In that case the subtest skips +without asserting the guest-specific behavior, so the run reports it as SKIP rather than as a pass +that covered nothing. It does not fail the suite. This is the one place the repo's no-skip rule is +waived, and the waiver is marked `//nolint:forbidigo` at the call: the rule exists to stop a +missing prerequisite from going unreported, which a silent early return does more thoroughly than +a skip. Supplying a real EE license to the container (extend `startEnv` in `container_test.go` +with `mmcontainer.WithLicense`) would let this subtest run to completion. diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go index 44ace50..cd8ac44 100644 --- a/server/e2e/helpers_test.go +++ b/server/e2e/helpers_test.go @@ -17,6 +17,7 @@ import ( "testing" mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -140,11 +141,16 @@ func spaceHasMember(t *testing.T, ctx context.Context, admin actor, spaceID, use // deleteSpace deletes spaceID via the space admin actor. Called at the end of TestScenarios via a // shared t.Cleanup over every space id the scenarios accumulated in spacesToClean. +// deleteSpace removes a space during teardown. A failure is reported and marks the test failed, but +// does not abort the calling goroutine: teardown deletes a list of spaces, and require's FailNow +// would skip every space after the first failure, leaving the rest behind. func deleteSpace(t *testing.T, ctx context.Context, admin actor, spaceID string) { t.Helper() status, body, err := doPluginRequest(ctx, admin.client, http.MethodDelete, "/spaces/"+spaceID, nil, nil) - require.NoError(t, err) - require.Equal(t, http.StatusOK, status, "cleanup: delete space %s: %s", spaceID, body) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, http.StatusOK, status, "cleanup: delete space %s: %s", spaceID, body) } // pageDocBody is a minimal valid page body (see model.Page.IsValid — only size-limited, not diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index dabb19e..d4f8e41 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -304,10 +304,15 @@ func TestScenarios(t *testing.T) { t.Fatalf("scenario6: demote failed with a non-AppError: %v", demoteErr) } if strings.Contains(appErr.Id, "license") { - t.Logf("scenario6: guest reviewer flow (real DemoteUserToGuest) is not testable in this environment — %s: %s. "+ - "The core image built by build/build-core-image.sh has no Enterprise license, and guest accounts require one. "+ - "Not asserted here; see README.md.", appErr.Id, appErr.Error()) - return + // Skipped rather than returned: a bare return reports this subtest as a pass + // having asserted nothing about the guest flow, which is indistinguishable from + // real coverage. The no-skip rule exists to stop a missing prerequisite from + // being papered over, and an unreported gap papers it over more thoroughly than + // a skip does. + //nolint:forbidigo // a visible gap beats a silent pass; see the note above + t.Skipf("scenario6: guest reviewer flow (real DemoteUserToGuest) needs an Enterprise license — %s: %s. "+ + "The core image built by build/build-core-image.sh has none, and guest accounts require one. "+ + "See README.md.", appErr.Id, appErr.Error()) } if !strings.Contains(appErr.Id, "disabled") { t.Fatalf("scenario6: demote failed unexpectedly: %s (%s)", appErr.Id, appErr.Error()) diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go index e7fbe20..1a1def8 100644 --- a/server/internal/testutil/schemestub.go +++ b/server/internal/testutil/schemestub.go @@ -139,9 +139,8 @@ func StubPresetSchemes(mockAPI *plugintest.API) { // run packages in parallel. var roleRegistry = struct { sync.RWMutex - byID map[string]*mmmodel.Role - byName map[string]*mmmodel.Role -}{byID: map[string]*mmmodel.Role{}, byName: map[string]*mmmodel.Role{}} + byID map[string]*mmmodel.Role +}{byID: map[string]*mmmodel.Role{}} func roleByID(roleID string) (*mmmodel.Role, bool) { roleRegistry.RLock() @@ -263,7 +262,6 @@ func StubRole(mockAPI *plugintest.API, roleName string, permissions []string) *m role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName, Permissions: permissions} roleRegistry.Lock() roleRegistry.byID[role.Id] = role - roleRegistry.byName[role.Name] = role roleRegistry.Unlock() mockAPI.On("GetRoleByName", roleName).Return(role, nil).Maybe() return role diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index b5017f1..3fab5c7 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -88,8 +88,9 @@ func stripReadPage(permissions []*mmmodel.Permission) []string { // spaceAdminEffectiveCapabilities is the full capability set a SchemeAdmin member effectively // holds, single-sourced from core's canonical admin permission slice (SpaceAdminRolePermissions, -// which already includes read_page). -var spaceAdminEffectiveCapabilities = mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions) +// which already includes read_page). Stored already normalized, like presetCapabilitySets below, +// so the accessor copies without re-deriving the canonical form on every call. +var spaceAdminEffectiveCapabilities = NormalizeCapabilitySet(mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions)) // presetCapabilitySets are the three seeded default-capability presets in wire form (read_page- // free — the baseline is implicit and never listed), single-sourced from core's canonical @@ -209,7 +210,9 @@ func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, // AdminEffectiveCapabilities returns the full capability set a SchemeAdmin effectively holds, wire // form, non-nil. func AdminEffectiveCapabilities() []string { - return NormalizeCapabilitySet(spaceAdminEffectiveCapabilities) + // Cloned, not returned directly: the set is package state a caller must not be able to mutate + // through the returned slice. + return slices.Clone(spaceAdminEffectiveCapabilities) } // DefaultCapabilitiesFromPermissions projects a pooled scheme's stored user-role permission set @@ -253,9 +256,14 @@ const sharedSchemeDisplayNamePrefix = "Space defaults: " // rather than the tokens themselves, which keeps the name inside core's 64-character // [a-z0-9_] limit and — unlike a positional encoding — leaves existing names meaning what they // always meant when the capability vocabulary grows. +// sharedSchemeNameDigestLength is how much of the digest the pool scheme name carries. Together +// with SharedSchemeNamePrefix it fits core's 64-character limit with room to spare, and 64 bits is +// far more than a vocabulary of a few tokens can collide within. +const sharedSchemeNameDigestLength = 16 + func SharedSchemeNameForCapabilities(capabilities []string) string { sum := sha256.Sum256([]byte(strings.Join(NormalizeCapabilitySet(capabilities), " "))) - return SharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:16] + return SharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:sharedSchemeNameDigestLength] } // SharedSchemeDisplayNameForCapabilities returns the operator-facing name of the pooled scheme for From 659eb7c29cb2fbff3fed71e5197a0fb97ed08eb6 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 5 Aug 2026 07:39:51 +0200 Subject: [PATCH 25/28] further restructuring(2) --- .github/workflows/ci.yml | 27 +++ server/api.go | 12 +- server/api_handler_test.go | 32 ++-- server/api_space.go | 4 +- server/app/permissions.go | 83 +++++---- server/app/permissions_test.go | 77 ++++++++ server/app/space.go | 140 ++------------- server/app/space_access.go | 170 ++++++++++++++++++ server/app/space_members.go | 30 ++-- server/app/space_test.go | 44 +++++ server/e2e/scenarios_test.go | 24 +-- server/internal/testutil/permstub.go | 13 ++ server/internal/testutil/schemestub.go | 5 +- server/model/space.go | 13 +- server/model/space_capabilities.go | 10 +- ... 000006_add_viewaccess_to_spaces.down.sql} | 0 ...=> 000006_add_viewaccess_to_spaces.up.sql} | 4 + 17 files changed, 459 insertions(+), 229 deletions(-) create mode 100644 server/app/space_access.go rename server/store/migrations/{000007_add_viewaccess_to_spaces.down.sql => 000006_add_viewaccess_to_spaces.down.sql} (100%) rename server/store/migrations/{000007_add_viewaccess_to_spaces.up.sql => 000006_add_viewaccess_to_spaces.up.sql} (57%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f41499..42a7fb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,33 @@ jobs: echo "Core CI publishes one image per commit as :<7-char-sha>." >&2 exit 1 fi + image_tag="${CORE_IMAGE##*:}" + + # The shape check above cannot catch the drift that matters. go.mod pins server/public to a + # core commit; CORE_IMAGE is a separate repository variable a human sets. Nothing ties them + # together, so bumping the pin without updating the variable leaves this suite green against + # a core image that predates the very changes under test. + gomod_commit="$(grep -oE 'server/public v[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.[0-9]+-[0-9a-f]+' go.mod | grep -oE '[0-9a-f]{12}$' || true)" + if [[ -z "$gomod_commit" ]]; then + echo "server/public is pinned to a released version, not a core commit; skipping the sha cross-check." + exit 0 + fi + if [[ ! "$image_tag" =~ ^[0-9a-f]{7,40}$ ]]; then + echo "go.mod pins server/public to unreleased core commit ${gomod_commit}, but CORE_IMAGE is release tag '${image_tag}'." >&2 + echo "A released image cannot carry that commit's changes. Point CORE_IMAGE at the core build for ${gomod_commit}." >&2 + exit 1 + fi + # Core CI tags images with a 7-char sha while go.mod carries 12, so compare on the shorter. + prefix_len=${#image_tag} + if (( ${#gomod_commit} < prefix_len )); then + prefix_len=${#gomod_commit} + fi + if [[ "${image_tag:0:$prefix_len}" != "${gomod_commit:0:$prefix_len}" ]]; then + echo "CORE_IMAGE sha '${image_tag}' does not match the core commit go.mod pins (${gomod_commit})." >&2 + echo "Set CORE_IMAGE to the image core CI published for ${gomod_commit}, or repin go.mod." >&2 + exit 1 + fi + echo "CORE_IMAGE ${image_tag} matches the go.mod core pin ${gomod_commit}." - name: ci/setup uses: mattermost/actions/plugin-ci/setup@0256d363493a1e6b9e361ea788c62a0a73bc00be diff --git a/server/api.go b/server/api.go index 329c7ac..93da2b7 100644 --- a/server/api.go +++ b/server/api.go @@ -50,11 +50,13 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/members", p.handleGetSpaceMembers).Methods(http.MethodGet) api.HandleFunc("/spaces/{space_id}/members", p.handleAddSpaceMember).Methods(http.MethodPost) api.HandleFunc("/spaces/{space_id}/members/{user_id}", p.handleRemoveSpaceMember).Methods(http.MethodDelete) - // Unlike PATCH /spaces/{space_id}, whose nil fields mean "leave unchanged", these two replace - // the capability set outright: the body carries the full set the target should end up with, and - // a token omitted from it is revoked. There is no add-one/remove-one form. - api.HandleFunc("/spaces/{space_id}/members/{user_id}/capabilities", p.handleSetSpaceMemberCapabilities).Methods(http.MethodPatch) - api.HandleFunc("/spaces/{space_id}/default-capabilities", p.handleSetSpaceDefaultCapabilities).Methods(http.MethodPatch) + // PUT, not PATCH: these two replace the capability set outright — the body carries the full set + // the target should end up with, and a token omitted from it is revoked. There is no + // add-one/remove-one form. PATCH on this API means "nil fields leave unchanged" (see + // PATCH /spaces/{space_id}), so a caller generalizing from that would read an omitted token as + // "leave it alone" and silently revoke it instead. + api.HandleFunc("/spaces/{space_id}/members/{user_id}/capabilities", p.handleSetSpaceMemberCapabilities).Methods(http.MethodPut) + api.HandleFunc("/spaces/{space_id}/default-capabilities", p.handleSetSpaceDefaultCapabilities).Methods(http.MethodPut) // Page collection. api.HandleFunc("/spaces/{space_id}/pages", p.handleGetSpacePages).Methods(http.MethodGet) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 1974dc1..0a0f9da 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -1873,7 +1873,7 @@ func TestHandler_SetSpaceMemberCapabilities_Forbidden(t *testing.T) { h := openTestPlugin(t, nil) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", mmmodel.NewId(), map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", mmmodel.NewId(), map[string]any{ "granted_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusForbidden, rec.Code) @@ -1895,7 +1895,7 @@ func TestHandler_SetSpaceMemberCapabilities_GuestTargetRejected(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusBadRequest, rec.Code) @@ -1928,7 +1928,7 @@ func TestHandler_SetSpaceMemberCapabilities_InvalidCapability(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": tc.capabilities, }) require.Equal(t, http.StatusBadRequest, rec.Code) @@ -1969,7 +1969,7 @@ func TestHandler_SetSpaceMemberCapabilities_Grant(t *testing.T) { space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "RO Space", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusOK, rec.Code) @@ -1999,7 +1999,7 @@ func TestHandler_SetSpaceMemberCapabilities_OmissionRevokesAdminForbidden(t *tes h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{}, }) require.Equal(t, http.StatusForbidden, rec.Code) @@ -2019,7 +2019,7 @@ func TestHandler_SetSpaceMemberCapabilities_SelfTargetForbidden(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+callerID+"/capabilities", callerID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+callerID+"/capabilities", callerID, map[string]any{ "granted_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusForbidden, rec.Code) @@ -2044,7 +2044,7 @@ func TestHandler_SetSpaceMemberCapabilities_LastAdminConflict(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{}, }) require.Equal(t, http.StatusConflict, rec.Code) @@ -2075,7 +2075,7 @@ func TestHandler_SetSpaceMemberCapabilities_EmptyDoesNotDemoteBelowDefault(t *te h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{}, }) require.Equal(t, http.StatusOK, rec.Code) @@ -2108,7 +2108,7 @@ func TestHandler_SetSpaceMemberCapabilities_PublishesEvent(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ "granted_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusOK, rec.Code) @@ -2201,7 +2201,7 @@ func TestHandler_SetSpaceDefaultCapabilities_Forbidden(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, mmmodel.NewId()) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusForbidden, rec.Code) @@ -2220,7 +2220,7 @@ func TestHandler_SetSpaceDefaultCapabilities_Allowed(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"comment_page"}, }) require.Equal(t, http.StatusOK, rec.Code) @@ -2236,7 +2236,7 @@ func TestHandler_SetSpaceDefaultCapabilities_Allowed(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"comment_page"}, }) require.Equal(t, http.StatusOK, rec.Code) @@ -2265,7 +2265,7 @@ func TestHandler_SetSpaceDefaultCapabilities_InvalidCapability(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": tc.capabilities, }) require.Equal(t, http.StatusBadRequest, rec.Code) @@ -2288,7 +2288,7 @@ func TestHandler_SetSpaceDefaultCapabilities_CreatesPooledScheme(t *testing.T) { h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"create_page"}, }) require.Equal(t, http.StatusOK, rec.Code) @@ -2327,7 +2327,7 @@ func TestHandler_SetSpaceDefaultCapabilities_ReusesPooledScheme(t *testing.T) { setDefaults := func(capabilities []string) { t.Helper() - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": capabilities, }) require.Equal(t, http.StatusOK, rec.Code) @@ -2371,7 +2371,7 @@ func TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp(t *testing h := openTestPlugin(t, mockAPI) space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ "default_capabilities": []string{"edit_page", "comment_page", "comment_page", "create_page", "delete_own_page"}, }) require.Equal(t, http.StatusOK, rec.Code) diff --git a/server/api_space.go b/server/api_space.go index 84a1db2..ecea1f4 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -223,7 +223,7 @@ func (p *Plugin) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, member) } -// handleSetSpaceMemberCapabilities handles PATCH /api/v1/spaces/{space_id}/members/{user_id}/capabilities. +// handleSetSpaceMemberCapabilities handles PUT /api/v1/spaces/{space_id}/members/{user_id}/capabilities. func (p *Plugin) handleSetSpaceMemberCapabilities(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) vars := mux.Vars(r) @@ -247,7 +247,7 @@ func (p *Plugin) handleSetSpaceMemberCapabilities(w http.ResponseWriter, r *http writeJSON(w, http.StatusOK, member) } -// handleSetSpaceDefaultCapabilities handles PATCH /api/v1/spaces/{space_id}/default-capabilities. +// handleSetSpaceDefaultCapabilities handles PUT /api/v1/spaces/{space_id}/default-capabilities. func (p *Plugin) handleSetSpaceDefaultCapabilities(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] diff --git a/server/app/permissions.go b/server/app/permissions.go index 346e75a..9e84862 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -71,21 +71,27 @@ func (s *Service) isComplianceEnabled() bool { } // hasOpenTeamFallthrough reports whether userID holds the non-member team read_public_channel -// fall-through into teamID's open spaces. Suppressed under compliance mode. -func (s *Service) hasOpenTeamFallthrough(userID, teamID string) bool { - return s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadPublicChannel) && !s.isComplianceEnabled() +// fall-through into teamID's open spaces. Suppressed under compliance mode. member is userID's +// already-resolved membership in teamID; pass nil when the caller does not hold one and this +// resolves the team row itself. +func (s *Service) hasOpenTeamFallthrough(member *mmmodel.TeamMember, userID, teamID string) bool { + if s.isComplianceEnabled() { + return false + } + return s.teamPermGranted(member, userID, teamID, mmmodel.PermissionReadPublicChannel) } // readResolutionFrom evaluates the read gate against space for userID, given the caller's -// already-resolved sysadmin and active-team-membership status. -func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, userID string) ReadResolution { +// already-resolved sysadmin status and team membership. A nil member means the caller is not an +// active member of the space's team. +func (s *Service) readResolutionFrom(sysadmin bool, member *mmmodel.TeamMember, space *model.Space, userID string) ReadResolution { if sysadmin { return ReadViaSysadmin } - if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionReadPage) { + if member != nil && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionReadPage) { return ReadViaMember } - if space.ViewAccess == model.ViewAccessOpen && active && s.hasOpenTeamFallthrough(userID, space.TeamId) { + if space.ViewAccess == model.ViewAccessOpen && member != nil && s.hasOpenTeamFallthrough(member, userID, space.TeamId) { return ReadViaOpenFallthrough } return ReadDenied @@ -93,16 +99,16 @@ func (s *Service) readResolutionFrom(sysadmin, active bool, space *model.Space, // ResolveSpaceRead resolves the read gate for space against userID, reporting how the read was // admitted so callers can gate auto-join to the fall-through case only. where identifies the -// calling operation for the 500 an isActiveTeamMember lookup failure surfaces as. On that +// calling operation for the 500 an activeTeamMember lookup failure surfaces as. On that // failure the returned resolution is ReadDenied but the error is non-nil, so callers must check // the error first — treating the resolution alone as authoritative would misreport an outage as // "not authorized". func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID string) (ReadResolution, *mmmodel.AppError) { - active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) if appErr != nil { return ReadDenied, appErr } - return s.readResolutionFrom(sysadmin, active, space, userID), nil + return s.readResolutionFrom(sysadmin, member, space, userID), nil } // requireActiveMemberGate performs the four checks that precede the permission-specific branches @@ -111,28 +117,30 @@ func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID stri // resolved): client wiring, existence-hiding on a nil space, the sysadmin override, and // active-team-membership resolution with its 500 on a genuine lookup failure. A non-nil appErr // must be returned by the caller immediately. Otherwise, when sysadmin is true the caller may -// return nil immediately; when it is false the caller continues with active to evaluate its own -// permission-specific branches. -func (s *Service) requireActiveMemberGate(where string, space *model.Space, userID string) (active, sysadmin bool, appErr *mmmodel.AppError) { +// return nil immediately; when it is false the caller continues with member to evaluate its own +// permission-specific branches. A nil member means the caller is not an active team member; the +// membership itself is returned rather than a bool so a caller needing a team permission resolves +// it from these roles instead of re-reading the row. +func (s *Service) requireActiveMemberGate(where string, space *model.Space, userID string) (member *mmmodel.TeamMember, sysadmin bool, appErr *mmmodel.AppError) { if appErr = s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { - return false, false, appErr + return nil, false, appErr } // A malformed user id is a caller fault, not a denial: it reports as a 400 so it stays // distinguishable from the existence-hiding 403 every genuine denial returns. if !mmmodel.IsValidId(userID) { - return false, false, mmmodel.NewAppError(where, "app.space.access.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + return nil, false, mmmodel.NewAppError(where, "app.space.access.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } if space == nil { - return false, false, existenceHidingForbidden(where) + return nil, false, existenceHidingForbidden(where) } if s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) { - return false, true, nil + return nil, true, nil } - active, err := s.isActiveTeamMember(space.TeamId, userID) + member, err := s.activeTeamMember(space.TeamId, userID) if err != nil { - return false, false, mmmodel.NewAppError(where, "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, false, mmmodel.NewAppError(where, "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - return active, false, nil + return member, false, nil } // RequireSpacePagePermission gates a page-scoped operation on perm: sysadmin override, then @@ -141,26 +149,27 @@ func (s *Service) requireActiveMemberGate(where string, space *model.Space, user // (including a nil space, mirroring a lookup miss upstream) yields the shared existence-hiding // 403. func (s *Service) RequireSpacePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission) *mmmodel.AppError { - active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) if appErr != nil { return appErr } if sysadmin { return nil } - return s.evaluatePagePermission(where, space, userID, perm, active) + return s.evaluatePagePermission(where, space, userID, perm, member != nil, member) } // evaluatePagePermission grants perm to an active member holding it on the backing channel, or — // for a read permission on an open space only — to an active team member via the non-member // fall-through. Any other case yields the shared existence-hiding 403. active is the caller's -// already-resolved team-membership status. -func (s *Service) evaluatePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission, active bool) *mmmodel.AppError { +// already-resolved team-membership status; member is the membership behind it when the caller +// holds one, and nil when active was established without reading the row. +func (s *Service) evaluatePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission, active bool, member *mmmodel.TeamMember) *mmmodel.AppError { if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, perm) { return nil } if perm.Id == mmmodel.PermissionReadPage.Id && space.ViewAccess == model.ViewAccessOpen && active && - s.hasOpenTeamFallthrough(userID, space.TeamId) { + s.hasOpenTeamFallthrough(member, userID, space.TeamId) { return nil } return existenceHidingForbidden(where) @@ -188,7 +197,7 @@ func (s *Service) RequireSpacePagePermissionFrom(where string, space *model.Spac if space == nil { return existenceHidingForbidden(where) } - return s.evaluatePagePermission(where, space, userID, perm, true) + return s.evaluatePagePermission(where, space, userID, perm, true, nil) } // ResolveSpacePageOwnOrAny decides a page operation that is granted by either of two permissions: @@ -233,18 +242,18 @@ func (s *Service) ResolveSpacePageOwnOrAny(space *model.Space, userID, anyWhere // the caller can already read. Callers pass the operation's own team permission: manage_space for // the manage tier, delete_space for delete/restore. func (s *Service) RequireSpaceAdminOrTeamPerm(where string, space *model.Space, userID string, teamPerm *mmmodel.Permission) *mmmodel.AppError { - active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) if appErr != nil { return appErr } if sysadmin { return nil } - if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { + if member != nil && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { return nil } - if s.readResolutionFrom(false, active, space, userID) != ReadDenied && - s.client.User.HasPermissionToTeam(userID, space.TeamId, teamPerm) { + if s.readResolutionFrom(false, member, space, userID) != ReadDenied && + s.teamPermGranted(member, userID, space.TeamId, teamPerm) { return nil } return existenceHidingForbidden(where) @@ -254,14 +263,14 @@ func (s *Service) RequireSpaceAdminOrTeamPerm(where string, space *model.Space, // capabilities) and admin-affecting member changes: sysadmin, or channel admin_space plus active // team membership. No team-manage_space branch — those knobs are stricter than ordinary manage. func (s *Service) RequireSpaceAdminOrSysadmin(where string, space *model.Space, userID string) *mmmodel.AppError { - active, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) if appErr != nil { return appErr } if sysadmin { return nil } - if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { + if member != nil && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { return nil } return existenceHidingForbidden(where) @@ -282,8 +291,7 @@ func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID // page-edit authority in the space. A draft is a pending page private to its author, so this // establishes only that the caller may contribute pages here at all; the exact permission the // content needs is enforced at publish (RequireSpacePublish), the point where the draft becomes -// state other users can see. Checking the looser pair here also keeps autosave off the page-liveness -// lookup that the precise choice would require. +// state other users can see. func (s *Service) RequireSpaceDraftWrite(where string, space *model.Space, userID string, admittedVia ReadResolution) (joined bool, appErr *mmmodel.AppError) { createJoined, createErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia) if createErr == nil { @@ -364,10 +372,9 @@ func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, ad } // A set of defaults that cannot grant perm can never admit this write, so the answer is settled - // before taking the space-keyed lock. The lock holds a dedicated connection and serializes every - // membership and scheme mutation on the space, so a caller looping writes it is not entitled to - // would otherwise contend with those mutations on every attempt. Only a clean negative - // short-circuits: a failed lookup falls through, leaving the in-lock check below authoritative. + // before taking the space-keyed lock, which holds a dedicated connection and serializes every + // membership and scheme mutation on the space. Only a clean negative short-circuits: a failed + // lookup falls through, leaving the in-lock check below authoritative. if granted, grantErr := s.DefaultRolesGrantPermission(space, perm); grantErr == nil && !granted { return false, nil } diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go index 7ed57de..49d1a3b 100644 --- a/server/app/permissions_test.go +++ b/server/app/permissions_test.go @@ -316,6 +316,83 @@ func TestResolveSpaceRead_ComplianceModeSuppressesOpenFallthrough(t *testing.T) "compliance mode must suppress the open-space non-member fall-through even though the caller holds read_public_channel") } +// teamManagerHarness seeds a space with the given view access whose acting user holds team +// manage_space but is not a member of the backing channel — the actor RequireSpaceAdminOrTeamPerm's +// read-gate conjunct exists to constrain. +func teamManagerHarness(t *testing.T, viewAccess model.ViewAccess) (*testHarness, *model.Space, string) { + t.Helper() + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + teamID := mmmodel.NewId() + // Both registered before the harness, whose catch-alls would otherwise answer first: + // mock.Mock matches expectations in registration order. + stubNonMember(mockAPI, userID) + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, mmmodel.PermissionAdminSpace).Return(false).Maybe() + mockAPI.On("HasPermissionToTeam", userID, teamID, mmmodel.PermissionManageSpace).Return(true).Maybe() + h := openTestServiceWithAPI(t, mockAPI) + + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + if space.ViewAccess != viewAccess { + updated, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &viewAccess}, space.UpdateAt, false) + require.NoError(t, err) + space = updated + } + return h, space, userID +} + +// TestRequireSpaceAdminOrTeamPerm_TeamPermNeedsReadOnPrivateSpace pins the read-gate conjunct on the +// team-permission branch: a team-wide manage_space grant authorizes managing only spaces the caller +// can already read. Here the caller holds manage_space on the team but is not a backing-channel +// member of a private space, so the read resolver denies them and the team grant must not admit +// them anyway. Without the conjunct, any team manage_space holder could patch or delete every +// private space in the team, including ones they cannot open. +func TestRequireSpaceAdminOrTeamPerm_TeamPermNeedsReadOnPrivateSpace(t *testing.T) { + h, space, userID := teamManagerHarness(t, model.ViewAccessPrivate) + + appErr := h.svc.RequireSpaceAdminOrTeamPerm("test", space, userID, mmmodel.PermissionManageSpace) + require.NotNil(t, appErr, "team manage_space must not admit a caller who cannot read the space") + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id, + "the denial must be the shared existence-hiding 403, not a distinguishable one") +} + +// TestRequireSpaceAdminOrTeamPerm_TeamPermAdmitsOnOpenSpace is the positive half of the pair above: +// the same caller, the same team grant, on an open space the read resolver admits them to via the +// non-member fall-through. Together the two pin the conjunct to the read gate specifically — this +// case failing would mean the gate rejects callers it should serve, rather than the conjunct being +// absent. +func TestRequireSpaceAdminOrTeamPerm_TeamPermAdmitsOnOpenSpace(t *testing.T) { + h, space, userID := teamManagerHarness(t, model.ViewAccessOpen) + + appErr := h.svc.RequireSpaceAdminOrTeamPerm("test", space, userID, mmmodel.PermissionManageSpace) + require.Nil(t, appErr, "team manage_space must admit a caller the read gate already admits") +} + +// TestResolveSpaceRead_GuestDeniedOpenFallthrough covers the guest exclusion from the open-space +// non-member read. Core's team_guest holds read_space but not read_public_channel, so a guest who +// is not a backing-channel member has no path into an open space they were never added to — +// unlike a plain team member, whom the fall-through admits. +func TestResolveSpaceRead_GuestDeniedOpenFallthrough(t *testing.T) { + mockAPI := &plugintest.API{} + guestID := mmmodel.NewId() + // Both registered before the harness: StubDefaultSpacePermissions grants read_page on the + // channel and read_public_channel on the team to any user, and mock.Mock matches expectations + // in registration order. + stubNonMember(mockAPI, guestID) + testutil.StubGuestTeamDefaults(mockAPI, guestID) + h := openTestServiceWithAPI(t, mockAPI) + + // The default fixture is open and the harness stubs an active team membership, so the guest + // clears the team gate and reaches the fall-through — where team_guest's missing + // read_public_channel is the only thing left to deny them. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, guestID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution, + "a guest lacking team read_public_channel must not be admitted to an open space by the fall-through") +} + // TestResolveSpaceRead_InvalidUserIDIsBadRequest keeps a malformed user id reporting as a caller // fault. Collapsing it into the existence-hiding 403 every genuine denial returns would make a // plumbing bug indistinguishable from an ordinary authorization failure. diff --git a/server/app/space.go b/server/app/space.go index 9d2f061..8e741ec 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -10,7 +10,6 @@ import ( "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" @@ -29,128 +28,6 @@ func validateSpaceMutableFields(where, description, icon string) *mmmodel.AppErr return nil } -// requireClient rejects the operation when the pluginapi client is not wired, which every -// membership-gated space operation depends on. where identifies the calling operation for the -// log line and the returned AppError; kv are its extra log context pairs. -func (s *Service) requireClient(where string, kv ...any) *mmmodel.AppError { - if s.client != nil { - return nil - } - s.log.Warn("pluginapi client not wired; denying access", append([]any{"operation", where}, kv...)...) - return mmmodel.NewAppError(where, "app.space.client_not_wired.app_error", nil, "", http.StatusInternalServerError) -} - -// isActiveTeamMember reports whether userID currently belongs to teamID. Core keeps removed -// team members as rows with DeleteAt set — and GetMember returns such a row without error — so -// a missing row and a soft-deleted row both read as "not a member". Space access must check -// this, not just backing-channel membership: leaving a team does not remove a user from the -// team's space channels, so channel membership alone would let a former team member keep using -// known space and page IDs. -func (s *Service) isActiveTeamMember(teamID, userID string) (bool, error) { - member, err := s.client.Team.GetMember(teamID, userID) - if err != nil { - if errors.Is(err, pluginapi.ErrNotFound) { - return false, nil - } - return false, err - } - return member.DeleteAt == 0, nil -} - -// forEachChannelMember visits every member of channelID page by page. Iteration ends early -// when visit returns stop=true or an error; the error is returned as-is. -func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel.ChannelMember) (stop bool, err error)) error { - for page := 0; ; page++ { - members, err := s.client.Channel.ListMembers(channelID, page, PerPageMaximum) - if err != nil { - return err - } - for _, cm := range members { - stop, visitErr := visit(cm) - if visitErr != nil { - return visitErr - } - if stop { - return nil - } - } - if len(members) < PerPageMaximum { - return nil - } - } -} - -// hasOtherAuthorizedMemberMatching reports whether space has at least one backing-channel member -// other than excludeUserID that satisfies matches and can still reach the space — one who is also -// an active member of the space's team. Former team members keep their channel-member rows after -// leaving the team, so counting raw rows would let the last reachable member be removed and leave -// the space stranded behind members who all fail the team half of the access gate. Iteration stops -// at the first match. The no-team branch below is unreachable through CreateSpace, which requires -// a team id. -func (s *Service) hasOtherAuthorizedMemberMatching(space *model.Space, excludeUserID string, matches func(cm *mmmodel.ChannelMember) bool) (bool, error) { - found := false - err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { - if cm.UserId == excludeUserID || !matches(cm) { - return false, nil - } - if space.TeamId == "" { - found = true - return true, nil - } - active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) - if activeErr != nil { - return false, activeErr - } - if active { - found = true - return true, nil - } - return false, nil - }) - if err != nil { - return false, err - } - return found, nil -} - -// otherAuthorizedMembers answers both reachability questions the removal guards ask — is there -// another member who can still reach the space, and is one of them an admin — in a single walk. -// The admin set is a subset of the reachable set, so a caller needing both would otherwise pay two -// full walks, each with its own per-member team lookup, while holding the space membership lock. -// Iteration stops once both answers are known, and a row that cannot change either answer is -// skipped before its team lookup. -func (s *Service) otherAuthorizedMembers(space *model.Space, excludeUserID string) (anyMember, anyAdmin bool, err error) { - err = s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { - if cm.UserId == excludeUserID { - return false, nil - } - // Once a reachable member is known, only an admin row can still teach us anything. - if anyMember && !cm.SchemeAdmin { - return false, nil - } - reachable := space.TeamId == "" - if !reachable { - active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) - if activeErr != nil { - return false, activeErr - } - reachable = active - } - if !reachable { - return false, nil - } - anyMember = true - if cm.SchemeAdmin { - anyAdmin = true - } - return anyMember && anyAdmin, nil - }) - if err != nil { - return false, false, err - } - return anyMember, anyAdmin, nil -} - // archiveOrphanChannel archives a backing channel when a later step in space creation fails, // to avoid an orphaned channel. reason describes the step that failed; cause is its error. func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) { @@ -231,19 +108,19 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili // Reject a creator who isn't an active member of the target team before standing up a backing // channel there — otherwise any authenticated user could create a real, visible channel in any // team by supplying its id. - active, memberErr := s.isActiveTeamMember(space.TeamId, userID) + member, memberErr := s.activeTeamMember(space.TeamId, userID) if memberErr != nil { // A transient/backend failure must not be misreported as "not a team member". return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) } - if !active { + if member == nil { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.not_team_member.app_error", nil, "", http.StatusForbidden) } // Team membership alone does not authorize creating a space in it: the caller must also hold // create_space on the team (or be sysadmin). Unlike the read/manage/delete gates, no space // exists yet here, so there is nothing to existence-hide behind — a plain 403 is correct. if !s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) && - !s.client.User.HasPermissionToTeam(userID, space.TeamId, mmmodel.PermissionCreateSpace) { + !s.teamPermGranted(member, userID, space.TeamId, mmmodel.PermissionCreateSpace) { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.forbidden.app_error", nil, "", http.StatusForbidden) } // Sanitize before it's used as the channel Header below — Space.PreSave sanitizes it again on @@ -606,18 +483,21 @@ func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([] if appErr := s.requireClient("GetSpacesForTeam", "team_id", teamID, "user_id", userID); appErr != nil { return nil, false, appErr } - active, memberErr := s.isActiveTeamMember(teamID, userID) + // One team-row read answers all three questions below. Core resolves HasPermissionToTeam by + // re-reading the team membership from the master DB on every call, so asking it twice more here + // would cost this listing three master reads of the same row per page. + member, memberErr := s.activeTeamMember(teamID, userID) if memberErr != nil { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) } - if !active { + if member == nil { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.not_team_member.app_error", nil, "", http.StatusForbidden) } if !s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) && - !s.client.User.HasPermissionToTeam(userID, teamID, mmmodel.PermissionReadSpace) { + !s.teamPermGranted(member, userID, teamID, mmmodel.PermissionReadSpace) { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.forbidden.app_error", nil, "", http.StatusForbidden) } - callerHasOpenFallthrough := active && s.hasOpenTeamFallthrough(userID, teamID) + callerHasOpenFallthrough := s.hasOpenTeamFallthrough(member, userID, teamID) spaces, err := s.store.GetSpacesForTeam(teamID, userID, callerHasOpenFallthrough, offset, limit) if err != nil { return nil, false, storeAppError("GetSpacesForTeam", err) diff --git a/server/app/space_access.go b/server/app/space_access.go new file mode 100644 index 0000000..b0f58d5 --- /dev/null +++ b/server/app/space_access.go @@ -0,0 +1,170 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// The low-level access primitives the space subsystem shares: client wiring, team-membership and +// team-permission resolution, and backing-channel member iteration. They belong to no single +// caller — space lifecycle (space.go), authorization (permissions.go), and membership +// (space_members.go) each build on them — so they live here rather than in whichever file happened +// to need them first. + +// requireClient rejects the operation when the pluginapi client is not wired, which every +// membership-gated space operation depends on. where identifies the calling operation for the +// log line and the returned AppError; kv are its extra log context pairs. +func (s *Service) requireClient(where string, kv ...any) *mmmodel.AppError { + if s.client != nil { + return nil + } + s.log.Warn("pluginapi client not wired; denying access", append([]any{"operation", where}, kv...)...) + return mmmodel.NewAppError(where, "app.space.client_not_wired.app_error", nil, "", http.StatusInternalServerError) +} + +// activeTeamMember returns userID's membership in teamID, or nil when there is none. Core keeps +// removed team members as rows with DeleteAt set — and GetMember returns such a row without error +// — so a missing row and a soft-deleted row both read as "not a member". Space access must check +// this, not just backing-channel membership: leaving a team does not remove a user from the +// team's space channels, so channel membership alone would let a former team member keep using +// known space and page IDs. +// +// The membership carries the team roles, so a caller that needs both "is a member" and "holds a +// team permission" answers both from this one row via teamPermGranted. +func (s *Service) activeTeamMember(teamID, userID string) (*mmmodel.TeamMember, error) { + member, err := s.client.Team.GetMember(teamID, userID) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, nil + } + return nil, err + } + if member.DeleteAt != 0 { + return nil, nil + } + return member, nil +} + +// isActiveTeamMember reports whether userID currently belongs to teamID, for callers that need +// only the answer and not the roles. +func (s *Service) isActiveTeamMember(teamID, userID string) (bool, error) { + member, err := s.activeTeamMember(teamID, userID) + return member != nil, err +} + +// teamPermGranted reports whether userID holds perm on teamID, answering from member's roles when +// that already settles it. member is a membership the caller has resolved, or nil. +// +// Core's HasPermissionToTeam re-reads the team row from the master DB on every call, so a caller +// holding the membership would pay for the same row twice. Resolving the roles in memory answers +// the granting case without that read. A negative is not conclusive — core also honours a +// system-role grant the team roles cannot express — so it defers to core rather than denying, +// which keeps the outcome identical to calling HasPermissionToTeam directly. +func (s *Service) teamPermGranted(member *mmmodel.TeamMember, userID, teamID string, perm *mmmodel.Permission) bool { + if member != nil && s.client.User.RolesGrantPermission(member.GetRoles(), perm.Id) { + return true + } + return s.client.User.HasPermissionToTeam(userID, teamID, perm) +} + +// forEachChannelMember visits every member of channelID page by page. Iteration ends early +// when visit returns stop=true or an error; the error is returned as-is. +func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel.ChannelMember) (stop bool, err error)) error { + for page := 0; ; page++ { + members, err := s.client.Channel.ListMembers(channelID, page, PerPageMaximum) + if err != nil { + return err + } + for _, cm := range members { + stop, visitErr := visit(cm) + if visitErr != nil { + return visitErr + } + if stop { + return nil + } + } + if len(members) < PerPageMaximum { + return nil + } + } +} + +// hasOtherAuthorizedMemberMatching reports whether space has at least one backing-channel member +// other than excludeUserID that satisfies matches and can still reach the space — one who is also +// an active member of the space's team. Former team members keep their channel-member rows after +// leaving the team, so counting raw rows would let the last reachable member be removed and leave +// the space stranded behind members who all fail the team half of the access gate. Iteration stops +// at the first match. The no-team branch below is unreachable through CreateSpace, which requires +// a team id. +func (s *Service) hasOtherAuthorizedMemberMatching(space *model.Space, excludeUserID string, matches func(cm *mmmodel.ChannelMember) bool) (bool, error) { + found := false + err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { + if cm.UserId == excludeUserID || !matches(cm) { + return false, nil + } + if space.TeamId == "" { + found = true + return true, nil + } + active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) + if activeErr != nil { + return false, activeErr + } + if active { + found = true + return true, nil + } + return false, nil + }) + if err != nil { + return false, err + } + return found, nil +} + +// otherAuthorizedMembers answers both reachability questions the removal guards ask — is there +// another member who can still reach the space, and is one of them an admin — in a single walk. +// The admin set is a subset of the reachable set, so a caller needing both would otherwise pay two +// full walks, each with its own per-member team lookup, while holding the space membership lock. +// Iteration stops once both answers are known, and a row that cannot change either answer is +// skipped before its team lookup. +func (s *Service) otherAuthorizedMembers(space *model.Space, excludeUserID string) (anyMember, anyAdmin bool, err error) { + err = s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { + if cm.UserId == excludeUserID { + return false, nil + } + // Once a reachable member is known, only an admin row can still teach us anything. + if anyMember && !cm.SchemeAdmin { + return false, nil + } + reachable := space.TeamId == "" + if !reachable { + active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) + if activeErr != nil { + return false, activeErr + } + reachable = active + } + if !reachable { + return false, nil + } + anyMember = true + if cm.SchemeAdmin { + anyAdmin = true + } + return anyMember && anyAdmin, nil + }) + if err != nil { + return false, false, err + } + return anyMember, anyAdmin, nil +} diff --git a/server/app/space_members.go b/server/app/space_members.go index 2d74dcf..a1a746c 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -19,7 +19,7 @@ import ( // like every other paginated method (page and perPage both clamped). The pluginapi member // listing is page-indexed rather than offset-based, so when the requested page comes back full a // one-row probe at the next page's first slot decides has-more. space is the caller's -// already-fetched record (from its manage gate), so no re-read here. +// already-fetched record, from its manage gate. func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { if space == nil { return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -70,8 +70,7 @@ func toSpaceMember(cm *mmmodel.ChannelMember, defaultCapabilities []string) *mod } // AddSpaceMember adds a user to space's backing channel at the space default (SchemeUser, no -// per-member grants). space is the caller's already-fetched record (from its manage gate), so no -// re-read here. +// per-member grants). space is the caller's already-fetched record, from its manage gate. func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.SpaceMember, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -168,7 +167,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st // channel's scheme) or SetSpaceMemberCapabilities/RemoveSpaceMember (the last-admin invariant) // call could change: a stale read taken before the lock lets a concurrent default-capabilities // repoint write a superseded scheme's role name, or a concurrent promotion/demotion flip admin - // status, under this operation's feet — so every one of them runs inside the space-keyed + // status, mid-operation — so every one of them runs inside the space-keyed // advisory lock, alongside the mutation itself. var appErr *mmmodel.AppError var newRoles string @@ -257,7 +256,7 @@ func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID st // resolves before the last-member/last-admin guards; an admin-target removal is additionally // escalation-guarded (RequireSpaceAdminOrSysadmin) and last-admin-guarded, both under the same // space-scoped advisory lock as SetSpaceMemberCapabilities' admin-revoke path. space is the -// caller's already-fetched record (from its gate), so no re-read here. +// caller's already-fetched record, from its gate. func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID string) *mmmodel.AppError { if space == nil { return mmmodel.NewAppError("RemoveSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -272,7 +271,7 @@ func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID str // The target lookup, its admin status, the escalation-guard decision, and the admin count all // read state a concurrent SetSpaceMemberCapabilities/RemoveSpaceMember call could change // (the last-admin invariant) — a stale admin-status read taken before the lock lets a - // concurrent promotion flip it under this operation's feet — so every one of them runs inside + // concurrent promotion flip it mid-operation — so every one of them runs inside // the space-keyed advisory lock, alongside the mutation itself. This applies to self-removal // too, since the last-admin invariant covers the sole admin's self-leave. var appErr *mmmodel.AppError @@ -283,13 +282,16 @@ func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID str appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) return appErr } - // Non-member target: a manage-gated caller removing someone else has already proven - // manage authority over this space, so there is nothing left to existence-hide behind - // and it gets a plain 404, matching SetSpaceMemberCapabilities. Self-removal returns the - // shared existence-hiding 403 whatever the space's ViewAccess — keying the code on - // ViewAccess would let a caller read a space's exposure setting off the status alone, - // the distinction the rest of the surface exists to deny. - if userID != actingUserID { + // Non-member target. Removing someone else means the caller already cleared the manage + // gate, so the space is not hidden from them and it is a plain 404, matching + // SetSpaceMemberCapabilities. + // + // Self-removal splits on ViewAccess. On a private space the caller is a non-member the + // read gate would have denied, so the existence-hiding 403 is what they must see. On an + // open space that gate admitted them by design and they can already read the space, so + // there is no existence left to hide and reporting the absent membership as 404 is both + // accurate and what the caller can act on. + if userID != actingUserID || space.ViewAccess == model.ViewAccessOpen { appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(memErr) } else { appErr = existenceHidingForbidden("RemoveSpaceMember") @@ -297,7 +299,7 @@ func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID str return appErr } - // Resolved before the scan below so an unauthorized caller is rejected without paying for it. + // Resolved before the scan below so an unauthorized caller is rejected without running it. if target.SchemeAdmin { if e := s.RequireSpaceAdminOrSysadmin("RemoveSpaceMember", space, actingUserID); e != nil { appErr = e diff --git a/server/app/space_test.go b/server/app/space_test.go index 5e3f05a..e28fae2 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -1095,6 +1095,50 @@ func TestServiceRemoveSpaceMember_RemoveFails(t *testing.T) { require.Equal(t, "app.space.remove_member.failed.app_error", appErr.Id) } +// TestServiceRemoveSpaceMember_SelfNonMemberOnOpenSpaceIs404 covers a non-member's self-removal +// from an open space. The read gate admits non-members to an open space by design, so the caller +// can already see it exists and there is nothing left to hide: the absent membership reports as a +// plain 404 rather than the existence-hiding 403, which would misreport a no-op as an +// authorization failure. +func TestServiceRemoveSpaceMember_SelfNonMemberOnOpenSpaceIs404(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, _ := createSpaceForMemberTests(t, h, mockAPI) + require.Equal(t, model.ViewAccessOpen, space.ViewAccess, "fixture must be open for this case") + + selfID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, selfID). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + + appErr := h.svc.RemoveSpaceMember(space, selfID, selfID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + require.Equal(t, "app.space.member.user_not_found.app_error", appErr.Id) +} + +// TestServiceRemoveSpaceMember_SelfNonMemberOnPrivateSpaceIs403 is the other half of the split: on +// a private space the same caller is a non-member the read gate denies, so reporting the absent +// membership as 404 would confirm the space exists to someone who cannot read it. They get the +// shared existence-hiding 403 instead. +func TestServiceRemoveSpaceMember_SelfNonMemberOnPrivateSpaceIs403(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, _ := createSpaceForMemberTests(t, h, mockAPI) + + private := model.ViewAccessPrivate + updated, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &private}, space.UpdateAt, false) + require.NoError(t, err) + + selfID := mmmodel.NewId() + mockAPI.On("GetChannelMember", updated.ChannelId, selfID). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + + appErr := h.svc.RemoveSpaceMember(updated, selfID, selfID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id) +} + // TestServiceRemoveSpaceMember_LastMemberRejected verifies the sole remaining member cannot be // removed: membership is the only gate on every space and page route, so a memberless space // would be permanently unreachable through the plugin API. diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go index d4f8e41..0101953 100644 --- a/server/e2e/scenarios_test.go +++ b/server/e2e/scenarios_test.go @@ -108,7 +108,7 @@ func TestScenarios(t *testing.T) { addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) var roResp pluginmodel.SpaceWithAccess - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/default-capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/default-capabilities", map[string][]string{"default_capabilities": {}}, &roResp) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "setting read-only default failed: %s", body) @@ -136,7 +136,7 @@ func TestScenarios(t *testing.T) { // The real grant surface: the space admin assigns create_page + edit_page to CONTRIB. var grantResp pluginmodel.SpaceMember - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage}}, &grantResp) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "granting CONTRIB capabilities: %s", body) @@ -222,7 +222,7 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusForbidden, status, "plain member create (comment default grants no create_page): %s", body) - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "granting CONTRIB create_page: %s", body) @@ -252,12 +252,12 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "granting A create_page: %s", body) - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+member.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+member.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "granting B edit_page: %s", body) @@ -353,7 +353,7 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusForbidden, status, "guest update: %s", body) - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+guestCandidate.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+guestCandidate.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, status, "granting a guest capabilities: %s", body) @@ -382,12 +382,12 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusCreated, status, "admin add member: %s", body) - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "admin set member capabilities: %s", body) - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID+"/default-capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+s7ID+"/default-capabilities", map[string][]string{"default_capabilities": {pluginmodel.CapabilityCommentPage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "admin set default capabilities: %s", body) @@ -418,12 +418,12 @@ func TestScenarios(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusForbidden, status, "control add member") - status, _, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", + status, _, err = doPluginRequest(ctx, member.client, http.MethodPut, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusForbidden, status, "control set member capabilities") - status, _, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s7ID+"/default-capabilities", + status, _, err = doPluginRequest(ctx, member.client, http.MethodPut, "/spaces/"+s7ID+"/default-capabilities", map[string][]string{"default_capabilities": {pluginmodel.CapabilityCommentPage}}, nil) require.NoError(t, err) require.Equal(t, http.StatusForbidden, status, "control set default capabilities") @@ -485,7 +485,7 @@ func TestScenarios(t *testing.T) { // the pooled scheme it leaves behind stays for the next space to request the same set. The // member loses create. var roResp pluginmodel.SpaceWithAccess - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/default-capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/default-capabilities", map[string][]string{"default_capabilities": {}}, &roResp) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "switch custom→preset failed: %s", body) @@ -543,7 +543,7 @@ func TestScenarios(t *testing.T) { require.Equal(t, http.StatusForbidden, status, "delete of an unowned page before the grant: %s", body) var granted pluginmodel.SpaceMember - status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", map[string][]string{"granted_capabilities": {pluginmodel.CapabilityDeletePage}}, &granted) require.NoError(t, err) require.Equal(t, http.StatusOK, status, "granting delete_page: %s", body) diff --git a/server/internal/testutil/permstub.go b/server/internal/testutil/permstub.go index f50cc7d..60561cf 100644 --- a/server/internal/testutil/permstub.go +++ b/server/internal/testutil/permstub.go @@ -46,6 +46,19 @@ func StubDefaultSpacePermissions(mockAPI *plugintest.API) { mockAPI.On("HasPermissionToTeam", mock.Anything, mock.Anything, p).Return(false).Maybe() } mockAPI.On("HasPermissionTo", mock.Anything, mmmodel.PermissionManageSystem).Return(false).Maybe() + // teamPermGranted consults the resolved team roles before falling back to HasPermissionToTeam. + // The stubbed team memberships carry no roles, so this arm decides nothing and the + // HasPermissionToTeam expectations above remain the ones that express a test's team grants. + // + // Scoped to the team permissions rather than registered as a catch-all: RolesGrantPermission is + // also how DefaultRolesGrantPermission resolves a space's *page* defaults, and a catch-all here + // would match those first and shadow the per-test expectations that drive the auto-join paths. + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadSpace, mmmodel.PermissionCreateSpace, mmmodel.PermissionReadPublicChannel, + mmmodel.PermissionManageSpace, mmmodel.PermissionDeleteSpace, + } { + mockAPI.On("RolesGrantPermission", mock.Anything, p.Id).Return(false).Maybe() + } } // StubGuestTeamDefaults narrows guestUserID's team grants to what core's team_guest actually holds: diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go index 1a1def8..ebc797a 100644 --- a/server/internal/testutil/schemestub.go +++ b/server/internal/testutil/schemestub.go @@ -336,7 +336,10 @@ func resolveChannelRoles(channel *mmmodel.Channel) (string, string, string, *mmm } roles, ok := rolesForScheme(*channel.SchemeId) if !ok { - return "", "", "", mmmodel.NewAppError("resolveChannelRoles", "app.scheme.get.app_error", nil, "", http.StatusNotFound) + // Built as a literal, like the other stand-ins for this core error above: the id belongs to + // core, and constructing it through NewAppError would enter the plugin's own translation + // file as an untranslated string it never emits. + return "", "", "", &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: http.StatusNotFound} } return roles.Guest, roles.User, roles.Admin, nil } diff --git a/server/model/space.go b/server/model/space.go index 0d4c1b7..5ceadf5 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -61,12 +61,13 @@ type Space struct { // effective set states what the caller may actually do rather than what was granted on top of the // baseline. Both are non-nil-on-empty. // -// An endpoint returns this wrapper when it establishes access state (CreateSpace) or changes it -// (SetSpaceDefaultCapabilities), and when it is read directly (GET /spaces/{id}). The endpoints -// that return a bare Space do so because they cannot change either field: a space PATCH alters -// metadata and view_access, neither of which moves the caller's capabilities or the space default, -// and the team listing omits them because resolving a capability set per space would cost a -// scheme-and-role lookup per row. +// An endpoint returns this wrapper when it establishes access state (CreateSpace), changes it +// (SetSpaceDefaultCapabilities), reads a space directly (GET /spaces/{id}), or patches one +// (PATCH /spaces/{id}): a patch may alter view_access, which moves who may read the space, and +// answering with the wrapper keeps a client refreshing its cached entry from dropping the +// capability fields. The endpoints that return a bare Space are the restore route, which alters +// neither field, and the team listing, which omits them because resolving a capability set per +// space would cost a scheme-and-role lookup per row. // // Because the embed is flat, a bare Space and this wrapper are indistinguishable to a client that // types them alike: a client caching a space must merge a bare-Space response into its cached diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go index 3fab5c7..705bd86 100644 --- a/server/model/space_capabilities.go +++ b/server/model/space_capabilities.go @@ -250,17 +250,17 @@ const SharedSchemeNamePrefix = "docs_space_default_" // the capability set the scheme grants. const sharedSchemeDisplayNamePrefix = "Space defaults: " +// sharedSchemeNameDigestLength is how much of the digest the pool scheme name carries. Together +// with SharedSchemeNamePrefix it fits core's 64-character limit with room to spare, and 64 bits is +// far more than a vocabulary of a few tokens can collide within. +const sharedSchemeNameDigestLength = 16 + // SharedSchemeNameForCapabilities returns the pool scheme name expressing capabilities: a // deterministic function of the capability set, so two spaces configured the same way resolve to // one shared scheme rather than each owning an identical private copy. The suffix is a digest // rather than the tokens themselves, which keeps the name inside core's 64-character // [a-z0-9_] limit and — unlike a positional encoding — leaves existing names meaning what they // always meant when the capability vocabulary grows. -// sharedSchemeNameDigestLength is how much of the digest the pool scheme name carries. Together -// with SharedSchemeNamePrefix it fits core's 64-character limit with room to spare, and 64 bits is -// far more than a vocabulary of a few tokens can collide within. -const sharedSchemeNameDigestLength = 16 - func SharedSchemeNameForCapabilities(capabilities []string) string { sum := sha256.Sum256([]byte(strings.Join(NormalizeCapabilitySet(capabilities), " "))) return SharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:sharedSchemeNameDigestLength] diff --git a/server/store/migrations/000007_add_viewaccess_to_spaces.down.sql b/server/store/migrations/000006_add_viewaccess_to_spaces.down.sql similarity index 100% rename from server/store/migrations/000007_add_viewaccess_to_spaces.down.sql rename to server/store/migrations/000006_add_viewaccess_to_spaces.down.sql diff --git a/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql b/server/store/migrations/000006_add_viewaccess_to_spaces.up.sql similarity index 57% rename from server/store/migrations/000007_add_viewaccess_to_spaces.up.sql rename to server/store/migrations/000006_add_viewaccess_to_spaces.up.sql index 2c68aef..0124e25 100644 --- a/server/store/migrations/000007_add_viewaccess_to_spaces.up.sql +++ b/server/store/migrations/000006_add_viewaccess_to_spaces.up.sql @@ -2,4 +2,8 @@ ALTER TABLE DOCS_Space ADD COLUMN IF NOT EXISTS ViewAccess VARCHAR(16) NOT NULL -- The DDL default is 'private' (fail-closed), so a row written without ViewAccess lands on the more -- restrictive access level. +-- +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the constraint is dropped first. That keeps this +-- file re-runnable as a whole, matching the guarded ADD COLUMN above. +ALTER TABLE DOCS_Space DROP CONSTRAINT IF EXISTS chk_docs_space_view_access; ALTER TABLE DOCS_Space ADD CONSTRAINT chk_docs_space_view_access CHECK (ViewAccess IN ('open', 'private')); From 217ad5c0bc632150fb6ed9b6dd228a928ff07771 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 5 Aug 2026 10:02:48 +0200 Subject: [PATCH 26/28] further restructuring(3) --- server/api_handler_test.go | 31 +++++++++ server/app/space.go | 4 +- server/app/space_access.go | 69 +++++-------------- server/app/space_members.go | 25 ++----- .../spaces_sidebar/space_item_menu.tsx | 4 +- 5 files changed, 58 insertions(+), 75 deletions(-) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 0a0f9da..4988bd0 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -853,6 +853,37 @@ func TestHandler_MovePageToSpace(t *testing.T) { require.Equal(t, spaceB.Id, moved.SpaceId) } +// TestHandler_MovePageToSpace_OwnPathRejectsUnownedDescendant drives the own-path subtree-ownership +// rule through the real HTTP route. The default harness grants delete_own_page but not delete_page, +// so the caller is admitted by the own permission, and the move must then require ownership of every +// live page in the moved subtree rather than of the reparented root alone. A descendant owned by +// another user rejects the move. +// +// The route is exercised end to end instead of calling MovePageToSpace with a hand-supplied owner +// id, so the own-versus-any resolution is itself under test: an admission reported as the any-page +// path carries no owner requirement into the store, and this move would then succeed. +func TestHandler_MovePageToSpace_OwnPathRejectsUnownedDescendant(t *testing.T) { + h := openTestPlugin(t, nil) + user := mmmodel.NewId() + other := mmmodel.NewId() + channelA := mmmodel.NewId() + spaceA := seedSpace(t, h.store, h.db, channelA) + root := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") + testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, other, root.Id) + + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) + require.NoError(t, err) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+root.Id+"/move-to-space", user, map[string]any{ + "target_space_id": spaceB.Id, + "expected_update_at": root.UpdateAt, + }) + require.Equal(t, http.StatusForbidden, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.page.move_to_space.subtree_not_owned.app_error", appErr.Id) +} + // TestHandler_MovePageToSpace_MissingTargetSpaceId verifies that omitting target_space_id returns // the handler-specific 400 before any app-layer call is made. func TestHandler_MovePageToSpace_MissingTargetSpaceId(t *testing.T) { diff --git a/server/app/space.go b/server/app/space.go index 8e741ec..fd2ab04 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -483,9 +483,7 @@ func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([] if appErr := s.requireClient("GetSpacesForTeam", "team_id", teamID, "user_id", userID); appErr != nil { return nil, false, appErr } - // One team-row read answers all three questions below. Core resolves HasPermissionToTeam by - // re-reading the team membership from the master DB on every call, so asking it twice more here - // would cost this listing three master reads of the same row per page. + // The membership resolved here answers all three questions below. member, memberErr := s.activeTeamMember(teamID, userID) if memberErr != nil { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) diff --git a/server/app/space_access.go b/server/app/space_access.go index b0f58d5..4e57050 100644 --- a/server/app/space_access.go +++ b/server/app/space_access.go @@ -26,7 +26,7 @@ func (s *Service) requireClient(where string, kv ...any) *mmmodel.AppError { if s.client != nil { return nil } - s.log.Warn("pluginapi client not wired; denying access", append([]any{"operation", where}, kv...)...) + s.log.Error("pluginapi client not wired; denying access", append([]any{"operation", where}, kv...)...) return mmmodel.NewAppError(where, "app.space.client_not_wired.app_error", nil, "", http.StatusInternalServerError) } @@ -63,11 +63,10 @@ func (s *Service) isActiveTeamMember(teamID, userID string) (bool, error) { // teamPermGranted reports whether userID holds perm on teamID, answering from member's roles when // that already settles it. member is a membership the caller has resolved, or nil. // -// Core's HasPermissionToTeam re-reads the team row from the master DB on every call, so a caller -// holding the membership would pay for the same row twice. Resolving the roles in memory answers -// the granting case without that read. A negative is not conclusive — core also honours a -// system-role grant the team roles cannot express — so it defers to core rather than denying, -// which keeps the outcome identical to calling HasPermissionToTeam directly. +// The roles on an already-resolved membership answer the granting case directly. A negative is not +// conclusive — core also honours a system-role grant the team roles cannot express — so it defers +// to core rather than denying, which keeps the outcome identical to calling HasPermissionToTeam +// directly. func (s *Service) teamPermGranted(member *mmmodel.TeamMember, userID, teamID string, perm *mmmodel.Permission) bool { if member != nil && s.client.User.RolesGrantPermission(member.GetRoles(), perm.Id) { return true @@ -98,45 +97,13 @@ func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel. } } -// hasOtherAuthorizedMemberMatching reports whether space has at least one backing-channel member -// other than excludeUserID that satisfies matches and can still reach the space — one who is also -// an active member of the space's team. Former team members keep their channel-member rows after -// leaving the team, so counting raw rows would let the last reachable member be removed and leave -// the space stranded behind members who all fail the team half of the access gate. Iteration stops -// at the first match. The no-team branch below is unreachable through CreateSpace, which requires -// a team id. -func (s *Service) hasOtherAuthorizedMemberMatching(space *model.Space, excludeUserID string, matches func(cm *mmmodel.ChannelMember) bool) (bool, error) { - found := false - err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { - if cm.UserId == excludeUserID || !matches(cm) { - return false, nil - } - if space.TeamId == "" { - found = true - return true, nil - } - active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) - if activeErr != nil { - return false, activeErr - } - if active { - found = true - return true, nil - } - return false, nil - }) - if err != nil { - return false, err - } - return found, nil -} - -// otherAuthorizedMembers answers both reachability questions the removal guards ask — is there -// another member who can still reach the space, and is one of them an admin — in a single walk. -// The admin set is a subset of the reachable set, so a caller needing both would otherwise pay two -// full walks, each with its own per-member team lookup, while holding the space membership lock. -// Iteration stops once both answers are known, and a row that cannot change either answer is -// skipped before its team lookup. +// otherAuthorizedMembers answers both reachability questions the membership guards ask — is there +// another member who can still reach the space, and is one of them an admin — in a single walk, +// disregarding excludeUserID (the member being demoted or removed). Former team members keep their +// channel-member rows after leaving the team, so counting raw rows would let the last reachable +// member be removed and leave the space stranded behind members who all fail the team half of the +// access gate. Iteration stops once both answers are known, and a row that cannot change either +// answer is skipped before its team lookup. func (s *Service) otherAuthorizedMembers(space *model.Space, excludeUserID string) (anyMember, anyAdmin bool, err error) { err = s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { if cm.UserId == excludeUserID { @@ -146,15 +113,11 @@ func (s *Service) otherAuthorizedMembers(space *model.Space, excludeUserID strin if anyMember && !cm.SchemeAdmin { return false, nil } - reachable := space.TeamId == "" - if !reachable { - active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) - if activeErr != nil { - return false, activeErr - } - reachable = active + active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) + if activeErr != nil { + return false, activeErr } - if !reachable { + if !active { return false, nil } anyMember = true diff --git a/server/app/space_members.go b/server/app/space_members.go index a1a746c..b973c68 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -86,14 +86,12 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac // it as an opaque failure; checking here keeps the status code honest and guarantees every // space member can pass the team half of the access gate — which the last-authorized-member // guard in RemoveSpaceMember relies on when deciding who can still reach the space. - if space.TeamId != "" { - active, memberErr := s.isActiveTeamMember(space.TeamId, userID) - if memberErr != nil { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) - } - if !active { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) - } + active, memberErr := s.isActiveTeamMember(space.TeamId, userID) + if memberErr != nil { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) + } + if !active { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) } defaultCapabilities, err := s.spaceDefaultCapabilities(space) if err != nil { @@ -115,21 +113,12 @@ func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.Spac return toSpaceMember(member, defaultCapabilities), nil } -// hasOtherAuthorizedAdmin reports whether space's backing channel still has a SchemeAdmin member -// who can reach the space once excludeUserID is disregarded. excludeUserID, when non-empty, is -// skipped, so the answer describes what would remain after that user is demoted or removed. A -// caller that also needs the any-member answer should use otherAuthorizedMembers, which resolves -// both in one walk. -func (s *Service) hasOtherAuthorizedAdmin(space *model.Space, excludeUserID string) (bool, error) { - return s.hasOtherAuthorizedMemberMatching(space, excludeUserID, func(cm *mmmodel.ChannelMember) bool { return cm.SchemeAdmin }) -} - // requireNotLastAdmin rejects an operation that would leave space without an admin who can still // reach it, disregarding excludeUserID (the member being demoted or removed). Callers run it inside // the space-keyed membership lock, alongside the mutation it guards. where attributes both the // lookup failure and the rejection to the calling operation. func (s *Service) requireNotLastAdmin(where string, space *model.Space, excludeUserID string) *mmmodel.AppError { - otherAdmin, err := s.hasOtherAuthorizedAdmin(space, excludeUserID) + _, otherAdmin, err := s.otherAuthorizedMembers(space, excludeUserID) if err != nil { return mmmodel.NewAppError(where, "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/webapp/src/components/spaces_sidebar/space_item_menu.tsx b/webapp/src/components/spaces_sidebar/space_item_menu.tsx index 4d5c9a5..51ce4f0 100644 --- a/webapp/src/components/spaces_sidebar/space_item_menu.tsx +++ b/webapp/src/components/spaces_sidebar/space_item_menu.tsx @@ -36,7 +36,9 @@ const SpaceItemMenu = ({space}: Props) => { // preferences (spec §4, Phase B). // { // id: 'favorite', - // label: , + // label: isFavorite ? + // : + // , // leadingIcon: , // onClick: () => onToggleFavorite(space.id), // }, From 14034a3226569f2b3a15fe03e7973c76dba14ad7 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 5 Aug 2026 15:19:47 +0200 Subject: [PATCH 27/28] further restructuring(4) --- assets/i18n/en.json | 4 ++++ server/api_handler_test.go | 39 ++++++++++++++++++++++++++++++ server/app/permissions_test.go | 37 +++++++++++++++++++++++++++++ server/app/scheme.go | 22 +++++++++++++++++ server/app/service.go | 20 ++++++++++++++++ server/app/space.go | 15 ++++++++++-- server/app/space_test.go | 43 ++++++++++++++++++++++++++++++++++ webapp/i18n/en.json | 4 ++++ 8 files changed, 182 insertions(+), 2 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index edf65aa..6576251 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -551,6 +551,10 @@ "id": "app.space.member.user_not_found.app_error", "translation": "The specified user could not be found." }, + { + "id": "app.space.preset_scheme_missing.app_error", + "translation": "The built-in space permission schemes are not available on this server." + }, { "id": "app.space.remove_member.failed.app_error", "translation": "Unable to remove the member from the space." diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 4988bd0..814aa70 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -2091,6 +2091,45 @@ func TestHandler_SetSpaceMemberCapabilities_LastAdminConflict(t *testing.T) { require.Nil(t, conflict.CurrentPage) } +// TestHandler_SetSpaceMemberCapabilities_OtherAdminAllowsDemote verifies the positive side of the +// last-admin guard: demoting an admin succeeds once another reachable admin remains. +// +// The member list deliberately places a non-admin ahead of the surviving admin. The guard's scan +// stops only once it has answered both of its questions, so a scan that stopped at the first +// reachable member would never see the admin behind it and would reject this demote as removing +// the last one. +func TestHandler_SetSpaceMemberCapabilities_OtherAdminAllowsDemote(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + plainMemberID := mmmodel.NewId() + otherAdminID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, nil) + mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). + Return(mmmodel.ChannelMembers{ + {ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, + {ChannelId: channelID, UserId: plainMemberID}, + {ChannelId: channelID, UserId: otherAdminID, SchemeAdmin: true}, + }, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.False(t, member.IsAdmin) +} + // TestHandler_SetSpaceMemberCapabilities_EmptyDoesNotDemoteBelowDefault verifies the additive-only // contract: granting the empty set to a plain member on a contribute-default space clears their // per-member grant but never demotes their effective capabilities below the space default. diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go index 49d1a3b..b547526 100644 --- a/server/app/permissions_test.go +++ b/server/app/permissions_test.go @@ -227,6 +227,43 @@ func TestRequireSpaceDraftWrite_LookupFailureIsNotADenial(t *testing.T) { mockAPI.AssertNumberOfCalls(t, "GetTeamMember", 1) } +// TestResolveSpacePageOwnOrAny_CheckFailureIsNotADenial pins the same error discipline on the +// own/any gate that TestRequireSpaceDraftWrite_LookupFailureIsNotADenial pins on the draft gate: +// a failure of either attempt surfaces as itself. +// +// The distinction the gate draws is between "neither permission grants this" — reported as +// admitted=false with no error, which the caller turns into its own 403 — and "the check could not +// be carried out", which must reach the caller as the failure it is. +// +// Both ownerMatches values are covered because an owner match is what opens the second attempt, +// and the failure must not be converted into a grant by it. Only the any-attempt's guard is +// exercised either way: both attempts resolve through the same client, so an any-attempt that +// reached a verdict at all guarantees the own-attempt can only deny or grant. +func TestResolveSpacePageOwnOrAny_CheckFailureIsNotADenial(t *testing.T) { + for name, ownerMatches := range map[string]bool{ + "owner does not match": false, + "owner matches": true, + } { + t.Run(name, func(t *testing.T) { + h := openTestService(t) + // An unwired client makes both permission attempts fail as a 500 rather than deny. + h.svc = app.New(h.store, nil, nil) + space := testutil.MustCreateSpace(t, h.store, mmmodel.NewId(), mmmodel.NewId()) + + ownOnly, admitted, appErr := h.svc.ResolveSpacePageOwnOrAny( + space, mmmodel.NewId(), + "any", mmmodel.PermissionDeletePage, + "own", mmmodel.PermissionDeleteOwnPage, + ownerMatches, app.ReadViaMember) + + require.NotNil(t, appErr, "a failed check must not be reported as an ordinary denial") + require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + require.False(t, admitted) + require.False(t, ownOnly) + }) + } +} + // TestRequireSpacePagePermissionFrom_FallthroughAdmitsReadOnly is the escalation guard on the // open-space fall-through: it exists to admit reads, and must never admit a write. A non-member // whose space default withholds the write permission — so the auto-join pre-step does not join diff --git a/server/app/scheme.go b/server/app/scheme.go index 1952d84..6f323a3 100644 --- a/server/app/scheme.go +++ b/server/app/scheme.go @@ -5,6 +5,7 @@ package app import ( "errors" + "slices" mmmodel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/pluginapi" @@ -13,6 +14,20 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) +// permissionSetsEqual reports whether two permission sets hold the same ids, disregarding order. +// The sets being compared are built differently — a generated user role leads with the baseline +// read followed by the sorted capabilities, the admin role follows core's declaration order, and a +// stored role comes back in whatever order it was last written. +func permissionSetsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + sortedA, sortedB := slices.Clone(a), slices.Clone(b) + slices.Sort(sortedA) + slices.Sort(sortedB) + return slices.Equal(sortedA, sortedB) +} + // A non-preset default-capability set resolves to a scheme in a shared pool keyed by that set, so // the number of schemes is bounded by the capability vocabulary rather than by the number of // spaces: every space configured the same way points at one scheme. A pooled scheme is never @@ -188,6 +203,13 @@ func (s *Service) setRolePermissions(roleName string, permissions []string) erro } return err } + // configureSharedScheme runs on every resolution, not only when the scheme is created, so that a + // scheme a racing caller left mid-configuration still converges. Once the stored set matches, + // rewriting it would invalidate the role in core's cache on every node, for every space sharing + // this pooled scheme, so a matching set is left as it stands. + if permissionSetsEqual(role.Permissions, permissions) { + return nil + } // Patched by id rather than by handing back the role just read: core re-reads the stored role // so its scope guard judges a SchemeId the caller cannot influence. if _, err = s.client.Role.Patch(role.Id, &mmmodel.RolePatch{Permissions: &permissions}); err != nil { diff --git a/server/app/service.go b/server/app/service.go index 84b9571..41f580e 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -136,6 +136,26 @@ func membershipLockAppError(where string, lockErr error) *mmmodel.AppError { return storeAppError(where, lockErr) } +// schemeAppError maps a scheme-resolution failure to an *AppError. Resolution reaches core through +// the pluginapi client rather than the plugin's own store, so the error may already be an +// *AppError carrying the status core chose — a license denial for a custom scheme, or the refusal +// the scheme API returns while core's permissions migration is still running. Those are surfaced +// unchanged; storeAppError would collapse both to a 500 because neither is a store sentinel. +// +// A missing preset scheme is separated out because storeAppError renders every not-found with the +// shared key ordinary row lookups use, which would report an unseeded server as though the caller +// had asked for a space that does not exist. +func schemeAppError(where string, err error) *mmmodel.AppError { + if errors.Is(err, errPresetSchemeMissing) { + return mmmodel.NewAppError(where, "app.space.preset_scheme_missing.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + var appErr *mmmodel.AppError + if errors.As(err, &appErr) { + return appErr + } + return storeAppError(where, err) +} + // storeAppError maps a store sentinel error to an *AppError with the conventional status code // and a shared message key (app.store.*); the where argument identifies the calling operation for logs. // This is the default for translating store errors; hand-roll an inline NewAppError only when a diff --git a/server/app/space.go b/server/app/space.go index fd2ab04..93939db 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -5,6 +5,7 @@ package app import ( "errors" + "fmt" "net/http" "slices" "unicode/utf8" @@ -15,6 +16,10 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) +// errPresetSchemeMissing tags a preset-scheme lookup that found nothing, which means core has not +// seeded the space schemes on this server. +var errPresetSchemeMissing = errors.New("preset space scheme is not seeded") + // validateSpaceMutableFields enforces the Description/Icon size caps shared by CreateSpace and // UpdateSpace. where identifies the calling operation for logs; the message keys are shared // across callers. @@ -58,6 +63,12 @@ func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, po if presetName, ok := model.SchemeNameForDefaultCapabilities(capabilities); ok { id, getErr := s.getSchemeIDByName(presetName) if getErr != nil { + // Core seeds the presets; the plugin only reads them. A miss therefore means the server + // is unseeded, not that the caller named something that does not exist, so it is tagged + // to keep it out of the shared not-found translation. + if store.IsErrNotFound(getErr) { + return "", nil, fmt.Errorf("%w: %s", errPresetSchemeMissing, presetName) + } return "", nil, getErr } return id, nil, nil @@ -147,7 +158,7 @@ func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabili schemeID, pooledRoles, schemeErr := s.resolveSpaceScheme(capabilities) if schemeErr != nil { - return nil, storeAppError("CreateSpace", schemeErr) + return nil, schemeAppError("CreateSpace", schemeErr) } s.log.Debug("Creating space", "team_id", space.TeamId, "user_id", userID) @@ -393,7 +404,7 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ targetSchemeID, pooledRoles, schemeErr := s.resolveSpaceScheme(requested) if schemeErr != nil { - return storeAppError("SetSpaceDefaultCapabilities", schemeErr) + return schemeAppError("SetSpaceDefaultCapabilities", schemeErr) } if targetSchemeID == currentSchemeID { // No-op: requested set already matches the live default. diff --git a/server/app/space_test.go b/server/app/space_test.go index e28fae2..fde4d30 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -100,6 +100,48 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { mockAPI.AssertExpectations(t) } +// TestServiceCreateSpace_PresetSchemeMissing verifies an unseeded server is reported as a server +// fault rather than as a missing space. Core seeds the preset schemes; a space create that cannot +// find one says nothing about what the caller asked for, so it must not share the not-found key +// every ordinary row lookup returns. +func TestServiceCreateSpace_PresetSchemeMissing(t *testing.T) { + mockAPI := &plugintest.API{} + // Registered ahead of the harness, whose StubPresetSchemes would otherwise match first. + mockAPI.On("GetSchemeByName", mmmodel.SchemeNameSpaceContribute). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + h := openTestServiceWithAPI(t, mockAPI) + + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Unseeded"}, mmmodel.NewId(), nil, nil) + + require.NotNil(t, appErr) + require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + require.Equal(t, "app.space.preset_scheme_missing.app_error", appErr.Id) + mockAPI.AssertNotCalled(t, "CreateChannel") +} + +// TestServiceCreateSpace_SchemeDenialKeepsStatus verifies a refusal core issues against the scheme +// API — a license gate, or the permissions migration still running — reaches the caller with the +// status core chose. Reporting it as a 500 would hide a condition the operator can act on. +func TestServiceCreateSpace_SchemeDenialKeepsStatus(t *testing.T) { + mockAPI := &plugintest.API{} + mockAPI.On("GetSchemeByName", mock.AnythingOfType("string")). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + // The status and id core's own gate returns: minting a custom scheme without the licence for it + // is reported as not-implemented, not as a permission denial. + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{StatusCode: http.StatusNotImplemented, Id: "api.scheme.create_scheme.license.error"}) + h := openTestServiceWithAPI(t, mockAPI) + + // A single capability matches no preset, so the create resolves through the shared pool. + capabilities := []string{model.CapabilityCreatePage} + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Unlicensed"}, mmmodel.NewId(), &capabilities, nil) + + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotImplemented, appErr.StatusCode) + require.Equal(t, "api.scheme.create_scheme.license.error", appErr.Id) + mockAPI.AssertNotCalled(t, "CreateChannel") +} + // TestServiceCreateSpace_ChannelIdRejected verifies a caller-supplied ChannelId is rejected // before any backing-channel side effect. func TestServiceCreateSpace_ChannelIdRejected(t *testing.T) { @@ -165,6 +207,7 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { require.Equal(t, channelID, saved.ChannelId) mockAPI.AssertNotCalled(t, "GetChannel", channelID) mockAPI.AssertNotCalled(t, "DeleteChannel", channelID) + mockAPI.AssertExpectations(t) } // TestServiceCreateSpace_InvalidInput verifies the up-front validations reject before any diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 40f3403..0f37edd 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -22,6 +22,10 @@ "docs.createSpace.title": "Create a new space", "docs.createSpace.urlAriaLabel": "Space URL", "docs.createSpace.visibilityLabel": "Space visibility", + "docs.editor.header.draft": "Draft · {spaceId} / {pageId}", + "docs.editor.header.published": "Published · {spaceId} / {pageId}", + "docs.editor.hostMissing": "This Mattermost build does not publish the Docs editor. Update the server to edit pages here.", + "docs.editor.stub.body": "Editor is available and will mount here. Suggestion providers exposed: {providerCount}.", "docs.form.url.edit": "Edit", "docs.form.url.label": "URL:", "docs.genericModal.close": "Close", From 00e9645f561b3f905646eef8598c8f3aac9bd664 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 5 Aug 2026 16:40:20 +0200 Subject: [PATCH 28/28] further restructuring(5) --- assets/i18n/en.json | 8 ++-- server/app/permissions.go | 9 ++-- server/app/permissions_test.go | 88 ++++++++++++++++++++++++++++++++-- server/app/scheme.go | 10 ++-- server/app/service.go | 3 +- server/app/space.go | 17 +++++-- server/app/space_members.go | 4 +- server/app/space_test.go | 55 ++++++++++++++++++++- 8 files changed, 169 insertions(+), 25 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 6576251..9e0599f 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -508,12 +508,12 @@ "translation": "Unable to verify team membership." }, { - "id": "app.space.get_pages.invalid_space_id.app_error", - "translation": "Invalid space ID." + "id": "app.space.get_members.failed.app_error", + "translation": "Unable to list the members of the space." }, { - "id": "app.space.list_members.failed.app_error", - "translation": "Unable to list the members of the space." + "id": "app.space.get_pages.invalid_space_id.app_error", + "translation": "Invalid space ID." }, { "id": "app.space.lock_timeout.app_error", diff --git a/server/app/permissions.go b/server/app/permissions.go index 9e84862..cd85e1a 100644 --- a/server/app/permissions.go +++ b/server/app/permissions.go @@ -57,15 +57,16 @@ func ExistenceHidingForbidden(where string) *mmmodel.AppError { return existenceHidingForbidden(where) } -// isComplianceEnabled reports whether ComplianceSettings.Enable is set and true. A nil client (or -// nil config) reports false, matching core's own SafeDereference default. +// isComplianceEnabled reports whether ComplianceSettings.Enable is set and true. When the setting +// cannot be read at all — no client, or no config — it reports true, so the fall-through this +// guards is suppressed rather than admitted: an undeterminable setting must not widen access. func (s *Service) isComplianceEnabled() bool { if s.client == nil { - return false + return true } cfg := s.client.Configuration.GetConfig() if cfg == nil { - return false + return true } return mmmodel.SafeDereference(cfg.ComplianceSettings.Enable) } diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go index b547526..1e013c0 100644 --- a/server/app/permissions_test.go +++ b/server/app/permissions_test.go @@ -64,9 +64,10 @@ func TestAutoJoin_NotFallthroughIsNoOp(t *testing.T) { } } -// TestAutoJoin_JoinsWhenDefaultGrants is the successful pre-step: a non-member admitted via the -// open-space fall-through, whose space default grants the permission, is added to the backing -// channel and the membership-added event is published. +// TestAutoJoin_JoinsWhenDefaultGrants covers the success path of the auto-join pre-step — the step +// that runs ahead of a write gate and can add the caller to the backing channel so the gate then +// passes. A non-member admitted via the open-space fall-through, whose space default grants the +// permission, is added to the channel and the membership-added event is published. func TestAutoJoin_JoinsWhenDefaultGrants(t *testing.T) { mockAPI := &plugintest.API{} userID := mmmodel.NewId() @@ -353,9 +354,32 @@ func TestResolveSpaceRead_ComplianceModeSuppressesOpenFallthrough(t *testing.T) "compliance mode must suppress the open-space non-member fall-through even though the caller holds read_public_channel") } +// TestResolveSpaceRead_UnreadableConfigSuppressesOpenFallthrough covers the fail-closed direction of +// the compliance check: a config that cannot be read at all suppresses the open-space non-member +// fall-through rather than admitting it. Reading an absent config as "compliance off" would widen +// access on exactly the signal that says the access policy is unknown. +func TestResolveSpaceRead_UnreadableConfigSuppressesOpenFallthrough(t *testing.T) { + mockAPI := &plugintest.API{} + strangerID := mmmodel.NewId() + // Both registered before the harness, whose catch-alls would otherwise answer first: mock.Mock + // matches expectations in registration order. + mockAPI.On("HasPermissionToChannel", strangerID, mock.Anything, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("GetConfig").Return((*mmmodel.Config)(nil)).Once() + h := openTestServiceWithAPI(t, mockAPI) + + // The default space fixture is open, and the harness stubs GetTeamMember to an active + // membership, so the stranger clears the team gate and reaches the open-space fall-through. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, strangerID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution, + "an unreadable config must suppress the open-space non-member fall-through, not admit it") +} + // teamManagerHarness seeds a space with the given view access whose acting user holds team -// manage_space but is not a member of the backing channel — the actor RequireSpaceAdminOrTeamPerm's -// read-gate conjunct exists to constrain. +// manage_space but is not a member of the backing channel — exactly the caller +// RequireSpaceAdminOrTeamPerm's read-gate conjunct exists to constrain. func teamManagerHarness(t *testing.T, viewAccess model.ViewAccess) (*testHarness, *model.Space, string) { t.Helper() mockAPI := &plugintest.API{} @@ -405,6 +429,60 @@ func TestRequireSpaceAdminOrTeamPerm_TeamPermAdmitsOnOpenSpace(t *testing.T) { require.Nil(t, appErr, "team manage_space must admit a caller the read gate already admits") } +// formerAdminHarness seeds an open space whose acting user left the team but whose backing-channel +// state still grants them everything the elevated gates check: admin_space on the channel and +// manage_space on the team. Every grant the gates consult is stubbed true, so a denial can only come +// from the team-active conjunct — which is what these tests exist to pin. +func formerAdminHarness(t *testing.T) (*testHarness, *model.Space, string, *plugintest.API) { + t.Helper() + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + teamID := mmmodel.NewId() + // All registered before the harness, whose catch-alls would otherwise answer first: mock.Mock + // matches expectations in registration order. + mockAPI.On("GetTeamMember", teamID, userID). + Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, mmmodel.PermissionAdminSpace).Return(true).Maybe() + mockAPI.On("HasPermissionToTeam", userID, teamID, mmmodel.PermissionManageSpace).Return(true).Maybe() + h := openTestServiceWithAPI(t, mockAPI) + + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + return h, space, userID, mockAPI +} + +// TestRequireSpaceAdminOrTeamPerm_FormerTeamMemberDenied pins the team-active conjunct on the +// channel-admin branch, the counterpart to TestRequireSpacePagePermission_FormerTeamMemberDenied on +// the page gate. Leaving a team does not remove the user's backing-channel rows, so a former member +// can still hold admin_space there. Without the conjunct that stale grant would keep admitting them +// to the manage tier — patch and delete on a space in a team they no longer belong to. +func TestRequireSpaceAdminOrTeamPerm_FormerTeamMemberDenied(t *testing.T) { + h, space, userID, mockAPI := formerAdminHarness(t) + + appErr := h.svc.RequireSpaceAdminOrTeamPerm("test", space, userID, mmmodel.PermissionManageSpace) + require.NotNil(t, appErr, "a former team member must not reach the manage tier through a stale channel admin_space grant") + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id, + "the denial must be the shared existence-hiding 403, not a distinguishable one") + // The team gate blocks before either elevated branch is consulted, so neither the lingering + // channel grant nor the team manage_space grant is ever reached. + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) +} + +// TestRequireSpaceAdminOrSysadmin_FormerTeamMemberDenied is the same guard on the stricter gate: +// the space-wide exposure knobs (ViewAccess, default capabilities) and admin-affecting member +// changes. This gate has no team-permission branch at all, so its channel-admin branch is the only +// non-sysadmin way in — making the team-active conjunct the whole of its membership requirement. +func TestRequireSpaceAdminOrSysadmin_FormerTeamMemberDenied(t *testing.T) { + h, space, userID, mockAPI := formerAdminHarness(t) + + appErr := h.svc.RequireSpaceAdminOrSysadmin("test", space, userID) + require.NotNil(t, appErr, "a former team member must not reach the admin tier through a stale channel admin_space grant") + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id, + "the denial must be the shared existence-hiding 403, not a distinguishable one") + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) +} + // TestResolveSpaceRead_GuestDeniedOpenFallthrough covers the guest exclusion from the open-space // non-member read. Core's team_guest holds read_space but not read_public_channel, so a guest who // is not a backing-channel member has no path into an open space they were never added to — diff --git a/server/app/scheme.go b/server/app/scheme.go index 6f323a3..5dfba49 100644 --- a/server/app/scheme.go +++ b/server/app/scheme.go @@ -34,8 +34,9 @@ func permissionSetsEqual(a, b []string) bool { // deleted — nothing owns it — so there is no retirement, no reference counting, and no residue // from an interrupted create. // -// Core accepts a scheme name as proof of space scope only for the three reserved preset names; a -// pooled scheme proves its scope by having a space backing channel point at it instead. +// Core accepts a scheme name as proof of space scope only for the reserved preset names +// (model.SchemeNameForDefaultCapabilities recognizes them); a pooled scheme proves its scope by +// having a space backing channel point at it instead. // schemeRoles is the generated channel-scheme role names governing one backing channel's scheme. // Space capability grants reference these generated names, not the literal @@ -171,8 +172,9 @@ func rolesFromScheme(scheme *mmmodel.Scheme) *schemeRoles { // rather than on whatever a channel currently points at. // // It must run only once a space backing channel already points at that scheme: core allows a role -// write carrying space permissions for a seeded preset's roles, or for a scheme a space backing -// channel already references, and it does not accept a caller-chosen scheme name as proof. +// write carrying space permissions for the roles of a reserved preset scheme (see the pool comment +// above), or for a scheme a space backing channel already references, and it does not accept a +// caller-chosen scheme name as proof. // Idempotent, so re-running it against an already-configured pooled scheme is a no-op in effect. func (s *Service) configureSharedScheme(roles *schemeRoles, capabilities []string) error { capabilities = model.NormalizeCapabilitySet(capabilities) diff --git a/server/app/service.go b/server/app/service.go index 41f580e..f2d91f4 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -139,7 +139,8 @@ func membershipLockAppError(where string, lockErr error) *mmmodel.AppError { // schemeAppError maps a scheme-resolution failure to an *AppError. Resolution reaches core through // the pluginapi client rather than the plugin's own store, so the error may already be an // *AppError carrying the status core chose — a license denial for a custom scheme, or the refusal -// the scheme API returns while core's permissions migration is still running. Those are surfaced +// the scheme API returns until core finishes the asynchronous permissions migration it runs at +// startup, during which no scheme can be resolved or created. Those are surfaced // unchanged; storeAppError would collapse both to a 500 because neither is a store sentinel. // // A missing preset scheme is separated out because storeAppError renders every not-found with the diff --git a/server/app/space.go b/server/app/space.go index 93939db..3431449 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -407,7 +407,18 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ return schemeAppError("SetSpaceDefaultCapabilities", schemeErr) } if targetSchemeID == currentSchemeID { - // No-op: requested set already matches the live default. + // The channel already points at the target scheme, so there is no repoint to perform. + // The roles behind that scheme are not necessarily written, though: an earlier run + // interrupted between the repoint below and its configure leaves the space resolving + // against core's default channel baseline, and every later submission of the same set + // would take this branch and never reach that configure. Running it here is what lets + // resubmitting the intended set recover such a space; it is a no-op in effect once the + // permission sets already match. + if pooledRoles != nil { + if cfgErr := s.configureSharedScheme(pooledRoles, requested); cfgErr != nil { + return schemeAppError("SetSpaceDefaultCapabilities", cfgErr) + } + } return nil } @@ -481,8 +492,8 @@ func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities [ // GetSpacesForTeam returns one page of a team's live spaces, plus whether more exist beyond it. // userID must be an active team member holding team read_space (the list-entry gate; every // team_user holds it by default). The result is the union of spaces the caller is a -// backing-channel member of and open spaces the caller can reach via the same team -// read_public_channel/compliance-mode conjunct as single-space read. +// backing-channel member of and open spaces the caller can reach on the same terms single-space +// read admits them: the caller holds team read_public_channel, and compliance mode is off. func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([]*model.Space, bool, *mmmodel.AppError) { if !mmmodel.IsValidId(teamID) { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.invalid_team_id.app_error", nil, "", http.StatusBadRequest) diff --git a/server/app/space_members.go b/server/app/space_members.go index b973c68..cb388d7 100644 --- a/server/app/space_members.go +++ b/server/app/space_members.go @@ -35,7 +35,7 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod perPage = ClampPerPage(perPage) channelMembers, err := s.client.Channel.ListMembers(space.ChannelId, page, perPage) if err != nil { - return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } members := make([]*model.SpaceMember, 0, len(channelMembers)) for _, cm := range channelMembers { @@ -48,7 +48,7 @@ func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*mod // beyond the current window. probe, probeErr := s.client.Channel.ListMembers(space.ChannelId, (page+1)*perPage, 1) if probeErr != nil { - return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) } hasMore = len(probe) > 0 } diff --git a/server/app/space_test.go b/server/app/space_test.go index fde4d30..a00f559 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -817,6 +817,57 @@ func TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack(t *testing mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) } +// TestServiceSetSpaceDefaultCapabilities_SameSchemeStillConfiguresRoles covers the recovery path for +// a space already pointing at the scheme its requested set resolves to, whose roles were never +// written — the state an earlier run interrupted between the channel repoint and the role write +// leaves behind. There is no repoint to perform, so the operation could return having done nothing, +// and every later submission of the intended set would do the same, leaving the space permanently on +// core's default channel baseline with no way to move off it. Resubmitting must write the roles. +func TestServiceSetSpaceDefaultCapabilities_SameSchemeStillConfiguresRoles(t *testing.T) { + mockAPI := &plugintest.API{} + sysadminID := mmmodel.NewId() + // Registered before the harness so the response's own-capability projection resolves via + // sysadmin, sidestepping the ReadViaMember channel-member lookup this test does not stub. + // mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionTo", sysadminID, mmmodel.PermissionManageSystem).Return(true) + h := openTestServiceWithAPI(t, mockAPI) + + capabilities := []string{model.CapabilityCreatePage} + pooledName := model.SharedSchemeNameForCapabilities(capabilities) + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := pooledName+"_user", pooledName+"_admin", pooledName+"_guest" + mockAPI.On("GetSchemeByName", pooledName).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: pooledName, + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + // Empty permission sets stand in for core's default channel baseline: the roles core generated + // alongside the scheme, never given the grants configureSharedScheme writes. + unconfiguredUserRole := testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + testutil.StubPatchRole(mockAPI) + + // The channel already points at that scheme, so the repoint branch is skipped entirely. + channelID := mmmodel.NewId() + testutil.StubChannelScheme(mockAPI, channelID, &mmmodel.Channel{ + Id: channelID, Type: mmmodel.ChannelTypeSpace, SchemeId: &schemeID, + }) + space := mustCreateSpace(t, h.store, h.db, channelID) + + updated, appErr := h.svc.SetSpaceDefaultCapabilities(space, capabilities, sysadminID) + + require.Nil(t, appErr) + require.Equal(t, capabilities, updated.DefaultCapabilities) + require.ElementsMatch(t, []string{model.CapabilityReadPage, model.CapabilityCreatePage}, + unconfiguredUserRole.Permissions, + "resubmitting the set a space already resolves to must write the roles it was left without") +} + // TestServiceSetSpaceDefaultCapabilities_ResponseReflectsRequestedNotStaleReadback covers the // projection guard: the response is built from the capability set written under the lock, not // from a fresh read of the roles that write just committed. GetRoleByName here always answers with @@ -1062,7 +1113,7 @@ func createSpaceForMemberTests(t *testing.T, h *testHarness, mockAPI *plugintest } // TestServiceGetSpaceMembers_ListFails verifies that a failed member listing on the backing -// channel propagates as a 500 with the list_members error key. +// channel propagates as a 500 with the get_members error key. func TestServiceGetSpaceMembers_ListFails(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) @@ -1074,7 +1125,7 @@ func TestServiceGetSpaceMembers_ListFails(t *testing.T) { _, _, appErr := h.svc.GetSpaceMembers(space, 0, 60) require.NotNil(t, appErr) require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) - require.Equal(t, "app.space.list_members.failed.app_error", appErr.Id) + require.Equal(t, "app.space.get_members.failed.app_error", appErr.Id) } // TestServiceDefaultRolesGrantPermission_ChannelWithoutScheme covers schemeRolesFromChannel's