diff --git a/appinfo/info.xml b/appinfo/info.xml
index 9ed1aa7004..40c29d34ff 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 2.1.35-unstable.20261012153000
+ 2.1.35-unstable.20261015190000
EUPL-1.2
Conduction
OpenRegister
@@ -404,6 +404,13 @@ Vrij en open source onder de EUPL-licentie.
1,664 tables, measured 2026-10-06). Last, after every step that
creates tasks. Idempotent. -->
OCA\OpenRegister\Repair\BackfillTaskSubjectLocation
+
+ OCA\OpenRegister\Repair\OpenSpecialRightsToEveryone
OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs
diff --git a/docs/Features/organisations.md b/docs/Features/organisations.md
index 9c4c605d4d..ceac5f721d 100644
--- a/docs/Features/organisations.md
+++ b/docs/Features/organisations.md
@@ -68,7 +68,7 @@ graph TB
### Authorization & RBAC (Role-Based Access Control)
- **Hierarchical Permissions**: Define CRUD permissions per entity type (register, schema, object, view, agent)
-- **Special Rights**: Grant special permissions like object_publish, agent_use, dashboard_view, llm_use
+- **Special Rights**: Grant special permissions: agent_use, llm_use and grant_manage
- **Group-Based**: Assign permissions to Nextcloud groups
- **Quick Authorization Checks**: Use singular entity names for efficient permission checking
@@ -301,9 +301,7 @@ The authorization field contains the complete RBAC configuration:
'update': ['admin'],
'delete': ['admin']
},
- 'object_publish': ['admin', 'publisher'],
'agent_use': ['admin', 'ai-user'],
- 'dashboard_view': ['admin', 'manager'],
'llm_use': ['admin', 'ai-user']
}
}
@@ -350,9 +348,7 @@ The authorization system uses **singular entity names** for easier permission ch
'update': ['admin', 'ai-specialist'],
'delete': ['admin']
},
- 'object_publish': ['admin', 'publisher'],
'agent_use': ['admin', 'ai-user', 'developer'],
- 'dashboard_view': ['admin', 'manager', 'viewer'],
'llm_use': ['admin', 'ai-user']
}
}
@@ -372,32 +368,59 @@ Each entity type has four standard CRUD permissions:
### Special Rights
-Beyond CRUD operations, special rights grant additional capabilities:
-
-| Right | Description | Common Groups |
-|-------|-------------|---------------|
-| **object_publish** | Publish objects to make them publicly available | 'publisher', 'editor', 'admin' |
-| **agent_use** | Use AI agents for processing and analysis | 'ai-user', 'developer', 'admin' |
-| **dashboard_view** | Access organisation dashboard and analytics | 'manager', 'admin', 'analyst' |
-| **llm_use** | Use Large Language Model features | 'ai-user', 'researcher', 'admin' |
-| **grant_manage** | List, add and remove the grants on records the holder does not own, and name their owning group. Enforced. An entry can be narrowed to a register and schemas. Only an administrator or the organisation's owner assigns it. | a service account's group |
+Beyond CRUD operations, special rights grant additional capabilities. Each is a
+list of Nextcloud group ids. `*` gives the right to every signed-in user (not
+accepted under `grant_manage`). An administrator always holds every right. Only
+an administrator or the organisation's owner may change who holds one: the API
+answers anybody else with 403, and the editor's Special Rights tab is read-only
+for them.
+
+| Right | What it guards | Read from | Default |
+|-------|----------------|-----------|---------|
+| **agent_use** | Running an agent: a chat message in a conversation with an agent (`POST /api/chat/send`, `POST /api/chat/stream`), and an MCP session that declares an agent (`initialize`, `tools/list`, `tools/call`). Enforced. | The agent's organisation | `["*"]` |
+| **llm_use** | Having a language model called on your behalf: every chat message (`POST /api/chat/send`, `POST /api/chat/stream`), including the title and summary calls it makes. Enforced. | The conversation's organisation | `["*"]` |
+| **grant_manage** | List, add and remove the grants on records the holder does not own, and name their owning group. Enforced. An entry can be narrowed to a register and schemas. | The record's organisation | `[]` |
+
+When the organisation an agent or conversation belongs to is not set, the
+instance's default organisation is read. When there is no default organisation
+either, nothing could have narrowed the right and it holds. A key the
+organisation does not carry at all holds for everybody, except `grant_manage`.
+
+**Upgrade.** Before this was enforced, every signed-in user could run agents and
+use the language model, whatever the lists said (new organisations got `[]`,
+most others `["admin"]`). On upgrade the repair step
+`OpenSpecialRightsToEveryone` adds `*` to `agent_use` and `llm_use` in every
+organisation, once, so nobody is refused. To narrow a right, take `*` out of
+its list in the Special Rights tab. A narrowed right is not reopened by a
+later upgrade.
+
+**Retired rights.** `object_publish` and `dashboard_view` are retired. They
+were stored and shown in the editor but never enforced: objects have no publish
+action, and the dashboard API is administrator only. An organisation that still
+has them in its authorization block loads as before; nothing reads them, and
+new organisations, the editor and the upgrade no longer write them.
+
+Semantic and hybrid search embed the query with an embedding model; that does
+not need `llm_use`.
### Permission Check Example
+A special right is read through `OrganisationSpecialRights`
+(`lib/Service/Rbac/OrganisationSpecialRights.php`):
+
```php
-// Quick authorization check using singular entity names
-$canCreateRegister = $organisationService->hasPermission(
- $organisation,
- 'register', // Entity type (singular)
- 'create', // Action
- $userId
+// May this user use something guarded by agent_use or llm_use?
+$mayChat = $specialRights->mayUse(
+ right: OrganisationSpecialRights::LLM_USE,
+ userId: $userId,
+ organisationUuid: $conversation->getOrganisation()
);
-// Check special right
-$canPublish = $organisationService->hasSpecialRight(
- $organisation,
- 'object_publish',
- $userId
+// Does this user hold grant_manage on this record?
+$mayManageGrants = $specialRights->holdsOn(
+ right: OrganisationSpecialRights::GRANT_MANAGE,
+ userGroups: $userGroups,
+ object: $record
);
```
@@ -677,9 +700,7 @@ $organisation->setAuthorization([
'update' => ['lab-managers'],
'delete' => ['lab-managers'],
],
- 'object_publish' => ['lab-managers'],
'agent_use' => ['researchers', 'lab-managers'],
- 'dashboard_view' => ['lab-managers'],
'llm_use' => ['researchers', 'lab-managers'],
]);
diff --git a/docs/features/organisation-roles.md b/docs/features/organisation-roles.md
index e40348aed9..0c9e166fe3 100644
--- a/docs/features/organisation-roles.md
+++ b/docs/features/organisation-roles.md
@@ -115,12 +115,12 @@ In addition to entity-type CRUD, organisations can define special action permiss
| Key | Controls |
|-----|---------|
-| `object_publish` | Who can publish/depublish objects |
-| `agent_use` | Who can execute agents |
-| `dashboard_view` | Who can access the dashboard |
-| `llm_use` | Who can use LLM features |
+| `agent_use` | Who can run an agent, in a chat or as an MCP session (enforced; read from the agent's organisation) |
+| `llm_use` | Who can have the language model called on their behalf in a chat (enforced; read from the conversation's organisation) |
| `grant_manage` | Who can manage the grants and the owning group of records they do not own (enforced; see `OrganisationSpecialRights`) |
+`*` gives a right to every signed-in user (not under `grant_manage`); new organisations start with `*` under `agent_use` and `llm_use`. `object_publish` and `dashboard_view` are retired: they were never enforced, and a block that still holds them is read as before and ignored. Only an administrator or the organisation's owner may change special rights. See [Organisations](../Features/organisations.md#special-rights) for the upgrade behaviour.
+
### Nextcloud Groups on Organisations
Each organisation has a `groups` field — an array of Nextcloud group IDs associated with it:
diff --git a/docs/l10n-ui-translation.md b/docs/l10n-ui-translation.md
index b2740c0d31..390285d39f 100644
--- a/docs/l10n-ui-translation.md
+++ b/docs/l10n-ui-translation.md
@@ -261,7 +261,7 @@ harvested. The failures are not typos — they are correct translations of a
Recurring offenders, all confirmed in this app:
- **`Right`** — a permissions-table header (`EditOrganisation.vue`, the "Special
- Rights" table listing `object_publish`). Sibling apps use the same English word
+ Rights" table listing `agent_use`). Sibling apps use the same English word
for text alignment, so the harvest yields "right-aligned" (`ru` "По правому
краю", `nl` "Rechts"). Needs the *legal/permission* sense: `Recht`, `Право`,
`Uprawnienie`, `Právo`.
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 56dcbeaa86..7c1e390687 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -801,7 +801,7 @@
},
"src/modals/organisation/EditOrganisation.vue": {
"@nextcloud/l10n-enforce-ellipsis": {
- "count": 8
+ "count": 6
},
"@nextcloud/no-deprecated-library-props": {
"count": 1
diff --git a/l10n/be.js b/l10n/be.js
index bb6a8a0854..e9ef676dab 100644
--- a/l10n/be.js
+++ b/l10n/be.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Усе",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Толькі адміністратар або ўладальнік гэтай арганізацыі можа змяняць яе спецыяльныя правы.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Праграма, якая пастаўляе або валодае гэтай табліцай, напрыклад `dossiq`. Пуста для табліцы, створанай адміністратарам.",
diff --git a/l10n/bg.js b/l10n/bg.js
index 96880d23aa..32af0f7971 100644
--- a/l10n/bg.js
+++ b/l10n/bg.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Всички",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Само администратор или собственикът на тази организация може да променя специалните ѝ права.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Приложението, което доставя или притежава тази таблица, например `dossiq`. Празно за таблица, създадена от администратор.",
diff --git a/l10n/bs.js b/l10n/bs.js
index 69d4e70a2e..e5b46a93d3 100644
--- a/l10n/bs.js
+++ b/l10n/bs.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Svi",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Samo administrator ili vlasnik ove organizacije može mijenjati njena posebna prava.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacija koja isporučuje ili posjeduje ovu tabelu, na primjer `dossiq`. Prazno za tabelu koju je napisao administrator.",
diff --git a/l10n/ca.js b/l10n/ca.js
index cd056d1a01..a7006c37a9 100644
--- a/l10n/ca.js
+++ b/l10n/ca.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Tothom",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Només un administrador o el propietari d'aquesta organització en pot canviar els drets especials.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicació que subministra o posseeix aquesta taula, per exemple `dossiq`. Buit per a una taula creada per un administrador.",
diff --git a/l10n/cs.js b/l10n/cs.js
index 4c02e2f7d4..532a9015d7 100644
--- a/l10n/cs.js
+++ b/l10n/cs.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Všichni",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Zvláštní práva této organizace může měnit pouze správce nebo její vlastník.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikace, která tuto tabulku dodává nebo vlastní, například `dossiq`. Prázdné u tabulky, kterou vytvořil správce.",
diff --git a/l10n/da.js b/l10n/da.js
index d3179bd246..bac2aaed0c 100644
--- a/l10n/da.js
+++ b/l10n/da.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Alle",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Kun en administrator eller ejeren af denne organisation kan ændre dens særlige rettigheder.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Appen, der leverer eller ejer denne tabel, for eksempel `dossiq`. Tom for en tabel, en administrator har oprettet.",
diff --git a/l10n/de.js b/l10n/de.js
index d7d5dd17ba..02322b387d 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Alle",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Nur ein Administrator oder der Eigentümer dieser Organisation kann ihre Sonderrechte ändern.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App, die diese Tabelle mitliefert oder besitzt, zum Beispiel `dossiq`. Leer bei einer Tabelle, die ein Administrator angelegt hat.",
diff --git a/l10n/el.js b/l10n/el.js
index ee09a21146..17eceee173 100644
--- a/l10n/el.js
+++ b/l10n/el.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Όλοι",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Μόνο ένας διαχειριστής ή ο κάτοχος αυτού του οργανισμού μπορεί να αλλάξει τα ειδικά δικαιώματά του.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Η εφαρμογή που παρέχει ή κατέχει αυτόν τον πίνακα, για παράδειγμα `dossiq`. Κενό για πίνακα που δημιούργησε διαχειριστής.",
diff --git a/l10n/en.js b/l10n/en.js
index f827348952..19a54351d9 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -2,6 +2,8 @@ OC.L10N.register(
"openregister",
{
"3": "3",
+ "Everyone": "Everyone",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Only an administrator or the owner of this organisation can change its special rights.",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.",
"Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.",
diff --git a/l10n/es.js b/l10n/es.js
index 38f1bb0cef..bc0cbac508 100644
--- a/l10n/es.js
+++ b/l10n/es.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Todos",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Solo un administrador o el propietario de esta organización puede cambiar sus derechos especiales.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicación que suministra o posee esta tabla, por ejemplo `dossiq`. Vacío para una tabla creada por un administrador.",
diff --git a/l10n/et.js b/l10n/et.js
index 84a153530c..161a06923f 100644
--- a/l10n/et.js
+++ b/l10n/et.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Kõik",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Selle organisatsiooni eriõigusi saab muuta ainult administraator või organisatsiooni omanik.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Rakendus, mis selle tabeli kaasa annab või omab, näiteks `dossiq`. Tühi, kui tabeli lõi administraator.",
diff --git a/l10n/fi.js b/l10n/fi.js
index 6ee565dede..d9d0f4d0c3 100644
--- a/l10n/fi.js
+++ b/l10n/fi.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Kaikki",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Vain ylläpitäjä tai tämän organisaation omistaja voi muuttaa sen erityisoikeuksia.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Sovellus, joka toimittaa tai omistaa tämän taulukon, esimerkiksi `dossiq`. Tyhjä, jos ylläpitäjä on laatinut taulukon.",
diff --git a/l10n/fr.js b/l10n/fr.js
index 4ce02c8562..f2c73b83ef 100644
--- a/l10n/fr.js
+++ b/l10n/fr.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Tout le monde",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Seul un administrateur ou le propriétaire de cette organisation peut modifier ses droits spéciaux.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Application qui fournit ou possède cette table, par exemple `dossiq`. Vide pour une table créée par un administrateur.",
diff --git a/l10n/ga.js b/l10n/ga.js
index be77c4ad55..68c4b651f1 100644
--- a/l10n/ga.js
+++ b/l10n/ga.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Gach duine",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Ní féidir ach le riarthóir nó le húinéir na heagraíochta seo a cearta speisialta a athrú.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "An aip a sholáthraíonn nó a bhfuil úinéireacht aici ar an tábla seo, mar shampla `dossiq`. Folamh i gcás tábla a chruthaigh riarthóir.",
diff --git a/l10n/hr.js b/l10n/hr.js
index e89bffbf04..b66d4ebabd 100644
--- a/l10n/hr.js
+++ b/l10n/hr.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Svi",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Samo administrator ili vlasnik ove organizacije može mijenjati njezina posebna prava.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacija koja isporučuje ili posjeduje ovu tablicu, na primjer `dossiq`. Prazno za tablicu koju je izradio administrator.",
diff --git a/l10n/hu.js b/l10n/hu.js
index 4f7ae600f8..e5c725affe 100644
--- a/l10n/hu.js
+++ b/l10n/hu.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Mindenki",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Csak egy rendszergazda vagy a szervezet tulajdonosa módosíthatja a szervezet különleges jogait.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Az alkalmazás, amely ezt a táblázatot szállítja vagy birtokolja, például `dossiq`. Üres, ha a táblázatot egy rendszergazda hozta létre.",
diff --git a/l10n/is.js b/l10n/is.js
index 8d99a0d3a5..70dd39119b 100644
--- a/l10n/is.js
+++ b/l10n/is.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Allir",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Aðeins stjórnandi eða eigandi þessarar stofnunar getur breytt sérréttindum hennar.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Forritið sem útvegar eða á þessa töflu, til dæmis `dossiq`. Autt fyrir töflu sem stjórnandi bjó til.",
diff --git a/l10n/it.js b/l10n/it.js
index e8b4e2a4e6..f737c76944 100644
--- a/l10n/it.js
+++ b/l10n/it.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Tutti",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Solo un amministratore o il proprietario di questa organizzazione può modificarne i diritti speciali.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App che fornisce o possiede questa tabella, per esempio `dossiq`. Vuoto per una tabella creata da un amministratore.",
diff --git a/l10n/lb.js b/l10n/lb.js
index 6bb48b1f6c..c47a35c24d 100644
--- a/l10n/lb.js
+++ b/l10n/lb.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Jiddereen",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Nëmmen en Administrator oder de Besëtzer vun dëser Organisatioun kann hir Sonnerrechter änneren.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App, déi dës Tabell matbréngt oder besëtzt, zum Beispill `dossiq`. Eidel bei enger Tabell, déi en Administrateur ugeluecht huet.",
diff --git a/l10n/lt.js b/l10n/lt.js
index 53d503f132..a933d9d468 100644
--- a/l10n/lt.js
+++ b/l10n/lt.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Visi",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Šios organizacijos specialiąsias teises gali keisti tik administratorius arba organizacijos savininkas.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Programa, kuri pateikia šią lentelę arba ją valdo, pavyzdžiui, `dossiq`. Tuščia, jei lentelę sukūrė administratorius.",
diff --git a/l10n/lv.js b/l10n/lv.js
index ff06fb429f..841979aecf 100644
--- a/l10n/lv.js
+++ b/l10n/lv.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Visi",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Šīs organizācijas īpašās tiesības var mainīt tikai administrators vai organizācijas īpašnieks.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Lietotne, kas piegādā vai pārvalda šo tabulu, piemēram, `dossiq`. Tukšs, ja tabulu izveidoja administrators.",
diff --git a/l10n/mk.js b/l10n/mk.js
index 2abb5e6a7e..a0f82a8ae8 100644
--- a/l10n/mk.js
+++ b/l10n/mk.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Сите",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Само администратор или сопственикот на оваа организација може да ги менува нејзините посебни права.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Апликацијата што ја испорачува или ја поседува оваа табела, на пример `dossiq`. Празно за табела што ја создал администратор.",
diff --git a/l10n/mt.js b/l10n/mt.js
index fa40858b53..1c2560023c 100644
--- a/l10n/mt.js
+++ b/l10n/mt.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Kulħadd",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Amministratur jew is-sid ta' din l-organizzazzjoni biss jista' jibdel id-drittijiet speċjali tagħha.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "L-applikazzjoni li tipprovdi jew li tippossjedi din it-tabella, pereżempju `dossiq`. Vojt għal tabella maħluqa minn amministratur.",
diff --git a/l10n/nb.js b/l10n/nb.js
index c5e2b445b5..ff6f78d988 100644
--- a/l10n/nb.js
+++ b/l10n/nb.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Alle",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Bare en administrator eller eieren av denne organisasjonen kan endre dens spesielle rettigheter.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Appen som leverer eller eier denne tabellen, for eksempel `dossiq`. Tom for en tabell en administrator har laget.",
diff --git a/l10n/nl.js b/l10n/nl.js
index 54ebbc735e..34d63585b5 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Iedereen",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Alleen een beheerder of de eigenaar van deze organisatie kan de speciale rechten wijzigen.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App die deze tabel meelevert of beheert, bijvoorbeeld `dossiq`. Leeg voor een tabel die een beheerder zelf heeft gemaakt.",
diff --git a/l10n/pl.js b/l10n/pl.js
index f91f12fb6e..2e35d15a05 100644
--- a/l10n/pl.js
+++ b/l10n/pl.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Wszyscy",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Tylko administrator lub właściciel tej organizacji może zmieniać jej prawa specjalne.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacja, która dostarcza tę tabelę lub jest jej właścicielem, na przykład `dossiq`. Puste dla tabeli utworzonej przez administratora.",
diff --git a/l10n/pt.js b/l10n/pt.js
index 0a5bc565fd..2acb2da988 100644
--- a/l10n/pt.js
+++ b/l10n/pt.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Todos",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Apenas um administrador ou o proprietário desta organização pode alterar os seus direitos especiais.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicação que fornece ou possui esta tabela, por exemplo `dossiq`. Vazio para uma tabela criada por um administrador.",
diff --git a/l10n/rm.js b/l10n/rm.js
index db804b3995..bff7d87373 100644
--- a/l10n/rm.js
+++ b/l10n/rm.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Tuts",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Mo in administratur u il possessur da questa organisaziun po midar ses dretgs spezials.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "L'app che furnescha u posseda questa tabella, per exempel `dossiq`. Vid per ina tabella creada d'in administratur.",
diff --git a/l10n/ro.js b/l10n/ro.js
index 455a22a8bf..f4232ad2c4 100644
--- a/l10n/ro.js
+++ b/l10n/ro.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Toată lumea",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Numai un administrator sau proprietarul acestei organizații îi poate modifica drepturile speciale.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicația care furnizează sau deține acest tabel, de exemplu `dossiq`. Gol pentru un tabel creat de un administrator.",
diff --git a/l10n/ru.js b/l10n/ru.js
index 6b429b77f7..d3a8e05799 100644
--- a/l10n/ru.js
+++ b/l10n/ru.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Все",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Только администратор или владелец этой организации может изменять её специальные права.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Приложение, которое поставляет эту таблицу или владеет ею, например `dossiq`. Пусто для таблицы, созданной администратором.",
diff --git a/l10n/sk.js b/l10n/sk.js
index 07e6f584dd..700107aa8f 100644
--- a/l10n/sk.js
+++ b/l10n/sk.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Všetci",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Osobitné práva tejto organizácie môže meniť iba správca alebo jej vlastník.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikácia, ktorá túto tabuľku dodáva alebo vlastní, napríklad `dossiq`. Prázdne pri tabuľke, ktorú vytvoril správca.",
diff --git a/l10n/sl.js b/l10n/sl.js
index eb8f7422a5..fd1bfc30f0 100644
--- a/l10n/sl.js
+++ b/l10n/sl.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Vsi",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Posebne pravice te organizacije lahko spreminja samo skrbnik ali lastnik organizacije.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacija, ki to tabelo dobavlja ali jo ima v lasti, na primer `dossiq`. Prazno za tabelo, ki jo je ustvaril skrbnik.",
diff --git a/l10n/sq.js b/l10n/sq.js
index 549980838a..904f80e2e4 100644
--- a/l10n/sq.js
+++ b/l10n/sq.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Të gjithë",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Vetëm një administrator ose pronari i kësaj organizate mund të ndryshojë të drejtat e saj të veçanta.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacioni që e ofron ose e zotëron këtë tabelë, për shembull `dossiq`. Bosh për një tabelë të krijuar nga një administrator.",
diff --git a/l10n/sr.js b/l10n/sr.js
index a6e3b8c40c..3d3069bc1e 100644
--- a/l10n/sr.js
+++ b/l10n/sr.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Сви",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Само администратор или власник ове организације може да мења њена посебна права.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Апликација која испоручује или поседује ову табелу, на пример `dossiq`. Празно за табелу коју је направио администратор.",
diff --git a/l10n/sv.js b/l10n/sv.js
index 3bbb89ffac..6b98e6df91 100644
--- a/l10n/sv.js
+++ b/l10n/sv.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Alla",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Endast en administratör eller ägaren till denna organisation kan ändra dess särskilda rättigheter.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Appen som levererar eller äger den här tabellen, till exempel `dossiq`. Tom för en tabell som en administratör har skapat.",
diff --git a/l10n/tr.js b/l10n/tr.js
index 52936493a3..2ac522b7cf 100644
--- a/l10n/tr.js
+++ b/l10n/tr.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Herkes",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Bu kuruluşun özel haklarını yalnızca bir yönetici veya kuruluşun sahibi değiştirebilir.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Bu tabloyu sağlayan veya sahiplenen uygulama, örneğin `dossiq`. Bir yönetici tarafından oluşturulan tablolar için boştur.",
diff --git a/l10n/uk.js b/l10n/uk.js
index 01e6aeede1..b48c8cf072 100644
--- a/l10n/uk.js
+++ b/l10n/uk.js
@@ -1,6 +1,8 @@
OC.L10N.register(
"openregister",
{
+ "Everyone": "Усі",
+ "Only an administrator or the owner of this organisation can change its special rights.": "Лише адміністратор або власник цієї організації може змінювати її спеціальні права.",
"3": "3",
"30": "30",
"App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Застосунок, що постачає цю таблицю або володіє нею, наприклад `dossiq`. Порожньо для таблиці, створеної адміністратором.",
diff --git a/lib/Controller/ChatStreamController.php b/lib/Controller/ChatStreamController.php
index a6c9e77ff6..71bbd92ea8 100644
--- a/lib/Controller/ChatStreamController.php
+++ b/lib/Controller/ChatStreamController.php
@@ -290,6 +290,27 @@ public function stream(): Response {
throw $e;
}
+ // Special rights (decision 239), before the model is called. The
+ // SSE headers are already on the wire, so a refusal is the same
+ // `forbidden` frame as a conversation that is not the caller's;
+ // ChatService::processMessage() checks again for every other path.
+ $agent = null;
+ if ($conversation->getAgentId() !== null) {
+ $agent = $this->agentMapper->find($conversation->getAgentId());
+ }
+
+ $missingRight = $this->chatService->missingRight(conversation: $conversation, agent: $agent, userId: $userId);
+ if ($missingRight !== null) {
+ $this->emitAndExit(
+ eventType: 'error',
+ payload: [
+ 'code' => 'forbidden',
+ 'message' => 'You do not hold the special right ' . $missingRight . ' in this organisation',
+ 'right' => $missingRight,
+ ]
+ );
+ }
+
// Persist the CnAiContext snapshot on the user-authored Message
// row that ChatService::processMessage will create. The
// orchestrator's Version1Date20260511130000 migration ships the
diff --git a/lib/Controller/OrganisationController.php b/lib/Controller/OrganisationController.php
index 2d70263c3b..8f85ecca5a 100644
--- a/lib/Controller/OrganisationController.php
+++ b/lib/Controller/OrganisationController.php
@@ -714,13 +714,15 @@ public function update(string $uuid): JSONResponse {
$data = $this->extractRequestData();
// A member may edit the organisation; only its owner or an administrator
- // may decide who manages the grants on its records.
- if ($this->changesGrantManage(organisation: $organisation, data: $data) === true
+ // may change who holds one of its special rights (decisions 238, 239).
+ $changedRights = $this->changedSpecialRights(organisation: $organisation, data: $data);
+ if ($changedRights !== []
&& $this->organisationService->isOrganisationAdmin($uuid) === false
) {
return new JSONResponse(
data: [
- 'error' => 'Only an administrator or the owner of this organisation may change the special right grant_manage',
+ 'error' => 'Only an administrator or the owner of this organisation may change its special rights',
+ 'rights' => $changedRights,
],
statusCode: Http::STATUS_FORBIDDEN
);
@@ -1078,29 +1080,43 @@ private function applySimpleFieldUpdates(object $organisation, array $data): voi
}//end applySimpleFieldUpdates()
/**
- * Whether a request would change who holds the special right `grant_manage`.
+ * The special rights a request would change who holds.
*
* The stored block is replaced as a whole, so a request that carries an
- * authorization block without the key takes the right away, and that counts.
+ * authorization block without a key changes that right too: it falls back
+ * to the right's default, `*` for the rights that are open by default and
+ * nobody for `grant_manage`. Both sides are read with that default, so a
+ * block that leaves out a key the organisation never set is no change.
*
- * @param object $organisation Organisation entity.
+ * @param Organisation $organisation Organisation entity.
* @param array $data Request data.
*
- * @return bool True when the request names a different list than the stored one.
+ * @return string[] The keys of the rights whose list would differ, empty when none.
*
- * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
*/
- private function changesGrantManage(object $organisation, array $data): bool {
+ private function changedSpecialRights(Organisation $organisation, array $data): array {
if (is_array($data['authorization'] ?? null) === false) {
- return false;
+ return [];
}
- $key = OrganisationSpecialRights::GRANT_MANAGE;
- $stored = ($organisation->getAuthorization()[$key] ?? []);
- $wanted = ($data['authorization'][$key] ?? []);
+ $stored = $organisation->getAuthorization();
+ $changed = [];
+ foreach (OrganisationSpecialRights::ALL as $key) {
+ $default = [];
+ if (in_array(needle: $key, haystack: OrganisationSpecialRights::OPEN_BY_DEFAULT, strict: true) === true) {
+ $default = [OrganisationSpecialRights::EVERYONE];
+ }
+
+ $before = ($stored[$key] ?? $default);
+ $after = ($data['authorization'][$key] ?? $default);
+ if ($after !== $before) {
+ $changed[] = $key;
+ }
+ }
- return $wanted !== $stored;
- }//end changesGrantManage()
+ return $changed;
+ }//end changedSpecialRights()
/**
* Apply array field updates
diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php
index 0972bafa88..cd7c3b7f0c 100644
--- a/lib/Db/Organisation.php
+++ b/lib/Db/Organisation.php
@@ -247,13 +247,18 @@ class Organisation extends Entity implements JsonSerializable {
* "object": {"create": [], "read": [], "update": [], "delete": []},
* "view": {"create": [], "read": [], "update": [], "delete": []},
* "agent": {"create": [], "read": [], "update": [], "delete": []},
- * "object_publish": [],
- * "agent_use": [],
- * "dashboard_view": [],
- * "llm_use": [],
+ * "agent_use": ["*"],
+ * "llm_use": ["*"],
* "grant_manage": []
* }
*
+ * The special rights are lists of group ids. `*` gives a right to every
+ * signed-in user; it is not accepted under `grant_manage`. Only an
+ * administrator or the organisation's owner may change them. See
+ * Service\Rbac\OrganisationSpecialRights. An older block may still carry
+ * the retired `object_publish` and `dashboard_view`; they are kept as stored
+ * and read by nothing.
+ *
* @var array|null Authorization rules as JSON structure
*/
protected ?array $authorization = null;
@@ -858,10 +863,8 @@ public function isMerged(): bool {
* update: array,
* delete: array
* },
- * object_publish: array,
- * agent_use: array,
- * dashboard_view: array,
- * llm_use: array,
+ * agent_use: list,
+ * llm_use: list,
* grant_manage: array
* }
*/
@@ -909,11 +912,12 @@ private function getDefaultAuthorization(): array {
'update' => [],
'delete' => [],
],
- 'object_publish' => [],
- 'agent_use' => [],
- 'dashboard_view' => [],
- 'llm_use' => [],
- // Enforced, unlike the four above: see Service\Rbac\OrganisationSpecialRights.
+ // Every signed-in user, until an administrator or the owner narrows
+ // it: a new organisation behaves as an upgraded one does after
+ // Repair\OpenSpecialRightsToEveryone. See Service\Rbac\OrganisationSpecialRights.
+ 'agent_use' => ['*'],
+ 'llm_use' => ['*'],
+ // Nobody: a right that decides who reads a record is not open by default.
'grant_manage' => [],
];
}//end getDefaultAuthorization()
diff --git a/lib/Repair/OpenSpecialRightsToEveryone.php b/lib/Repair/OpenSpecialRightsToEveryone.php
new file mode 100644
index 0000000000..11749428ab
--- /dev/null
+++ b/lib/Repair/OpenSpecialRightsToEveryone.php
@@ -0,0 +1,133 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-License-Identifier: EUPL-1.2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Repair;
+
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
+use OCP\IAppConfig;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Repair step: write `*` under each special right a caller uses, in every organisation, once.
+ *
+ * WHY. Until decision 239 the special rights were stored and read by nothing,
+ * so every signed-in user could run an agent and have a language model called.
+ * The lists an organisation carries say otherwise: a new organisation got `[]`,
+ * and most got `["admin"]` from the admin-group backfill. Reading those lists
+ * as they stand would refuse nearly everybody on the day of the upgrade. This
+ * step writes what the instance actually did, `*` beside whatever groups are
+ * listed, so nothing is refused until an administrator or the owner narrows
+ * the list by taking `*` out.
+ *
+ * ONCE. The step records that it ran in app config and does nothing after that.
+ * Run again on every upgrade, it would put back a `*` an administrator took out.
+ * When it fails part-way the flag is not set, so the next upgrade finishes it;
+ * an organisation that already holds `*` is not written twice.
+ *
+ * Never throws: an organisation that cannot be written must not abort an app
+ * upgrade.
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+class OpenSpecialRightsToEveryone implements IRepairStep {
+
+ /**
+ * App config key that records the step ran.
+ *
+ * @var string
+ */
+ public const DONE_KEY = 'special_rights_opened_to_everyone';
+
+ /**
+ * Constructor.
+ *
+ * @param OrganisationSpecialRights $specialRights The one writer of special rights.
+ * @param IAppConfig $appConfig Records that the step ran.
+ * @param LoggerInterface $logger Logger.
+ *
+ * @return void
+ */
+ public function __construct(
+ private readonly OrganisationSpecialRights $specialRights,
+ private readonly IAppConfig $appConfig,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Human-readable step name surfaced in occ and the admin UI.
+ *
+ * @return string The step name.
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ public function getName(): string {
+ return 'Keep every user able to use agents and language models now that special rights are enforced';
+ }//end getName()
+
+ /**
+ * Write `*` under each open-by-default special right in every organisation, once.
+ *
+ * @param IOutput $output Migration output handle.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ public function run(IOutput $output): void {
+ if ($this->appConfig->getValueBool('openregister', self::DONE_KEY, false) === true) {
+ return;
+ }
+
+ $changed = 0;
+ $organisations = 0;
+ try {
+ foreach (OrganisationSpecialRights::OPEN_BY_DEFAULT as $right) {
+ $answer = $this->specialRights->openToEveryoneInEveryOrganisation(right: $right);
+ $changed += $answer['changed'];
+ $organisations = $answer['organisations'];
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: '[OpenSpecialRightsToEveryone] Could not open every special right; the next upgrade finishes it',
+ context: ['file' => __FILE__, 'line' => __LINE__, 'error' => $e->getMessage()]
+ );
+ $output->warning('Special rights were not opened in every organisation: ' . $e->getMessage());
+ return;
+ }
+
+ $this->appConfig->setValueBool('openregister', self::DONE_KEY, true);
+
+ $output->info(
+ sprintf(
+ 'Wrote "*" (every signed-in user) under %d special right list(s) across %d organisation(s). '
+ . 'Take "*" out of a list to narrow that right to its groups.',
+ $changed,
+ $organisations
+ )
+ );
+ }//end run()
+}//end class
diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php
index ad48818c37..bf71bf3dbc 100644
--- a/lib/Service/ChatService.php
+++ b/lib/Service/ChatService.php
@@ -40,6 +40,7 @@
use OCA\OpenRegister\Service\Chat\ResponseGenerationHandler;
use OCA\OpenRegister\Service\Chat\StreamYieldChannel;
use OCA\OpenRegister\Service\Chat\ToolManagementHandler;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use Psr\Log\LoggerInterface;
/**
@@ -152,8 +153,12 @@ class ChatService {
* @param MessageHistoryHandler $historyHandler History handler.
* @param ToolManagementHandler $toolHandler Tool handler.
* @param LoggerInterface $logger Logger.
+ * @param OrganisationSpecialRights $specialRights Reads agent_use and llm_use.
*
* @return void
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) The facade's eight handlers and mappers, plus the
+ * special-rights reader every message is checked against.
*/
public function __construct(
ConversationMapper $conversationMapper,
@@ -165,6 +170,7 @@ public function __construct(
MessageHistoryHandler $historyHandler,
ToolManagementHandler $toolHandler,
LoggerInterface $logger,
+ private readonly OrganisationSpecialRights $specialRights,
) {
$this->conversationMapper = $conversationMapper;
$this->messageMapper = $messageMapper;
@@ -246,6 +252,13 @@ public function processMessage(
$agent = $this->agentMapper->find($conversation->getAgentId());
}
+ // Special rights (decision 239), before anything is stored or sent
+ // to a model. Every caller of the LLM below comes through here.
+ $missing = $this->missingRight(conversation: $conversation, agent: $agent, userId: $userId);
+ if ($missing !== null) {
+ throw new Exception('You do not hold the special right ' . $missing . ' in this organisation', 403);
+ }
+
// Capture the CnAiContext snapshot under its own name before
// the retrieveContext() call below reuses `$context` for the
// RAG context object. Without this rename the snapshot would
@@ -359,6 +372,46 @@ public function processMessage(
}//end try
}//end processMessage()
+ /**
+ * The special right a caller lacks to send a message in a conversation, or null.
+ *
+ * `llm_use` is read from the conversation's organisation: every message
+ * calls a language model on the caller's behalf. `agent_use` is read from
+ * the agent's organisation when the conversation runs an agent.
+ * {@see OrganisationSpecialRights::mayUse()} for administrators, a missing
+ * organisation and a missing key.
+ *
+ * @param Conversation $conversation The conversation.
+ * @param Agent|null $agent The agent it runs, or null.
+ * @param string $userId The caller.
+ *
+ * @return string|null The right's key, or null when the caller holds both.
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ public function missingRight(Conversation $conversation, ?Agent $agent, string $userId): ?string {
+ if ($this->specialRights->mayUse(
+ right: OrganisationSpecialRights::LLM_USE,
+ userId: $userId,
+ organisationUuid: $conversation->getOrganisation()
+ ) === false
+ ) {
+ return OrganisationSpecialRights::LLM_USE;
+ }
+
+ if ($agent !== null
+ && $this->specialRights->mayUse(
+ right: OrganisationSpecialRights::AGENT_USE,
+ userId: $userId,
+ organisationUuid: $agent->getOrganisation()
+ ) === false
+ ) {
+ return OrganisationSpecialRights::AGENT_USE;
+ }
+
+ return null;
+ }//end missingRight()
+
/**
* Generate conversation title from first message
*
diff --git a/lib/Service/Mcp/McpAgentScope.php b/lib/Service/Mcp/McpAgentScope.php
index 43358483a9..84ca4b91be 100644
--- a/lib/Service/Mcp/McpAgentScope.php
+++ b/lib/Service/Mcp/McpAgentScope.php
@@ -28,6 +28,7 @@
use OCA\OpenRegister\Db\Agent;
use OCA\OpenRegister\Db\AgentMapper;
use OCA\OpenRegister\Service\Capability\ToolGrantResolver;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use OCP\ICache;
use OCP\ICacheFactory;
use Psr\Log\LoggerInterface;
@@ -73,6 +74,7 @@ class McpAgentScope {
* @param McpToolsService $toolsService The MCP tool catalogue
* @param ICacheFactory $cacheFactory Cache factory
* @param LoggerInterface $logger Logger
+ * @param OrganisationSpecialRights $specialRights Reads agent_use from the agent's organisation
*/
public function __construct(
private readonly AgentMapper $agentMapper,
@@ -80,6 +82,7 @@ public function __construct(
private readonly McpToolsService $toolsService,
ICacheFactory $cacheFactory,
private readonly LoggerInterface $logger,
+ private readonly OrganisationSpecialRights $specialRights,
) {
$this->cache = $cacheFactory->createDistributed(prefix: 'openregister_mcp_agent');
}//end __construct()
@@ -242,6 +245,19 @@ private function loadAgent(string $agentUuid, string $userId): Agent {
throw new InvalidArgumentException(message: 'Agent ' . $agentUuid . ' is not available to this user');
}
+ // Acting as an agent is running it (decision 239). Read on every call,
+ // like the grant, so narrowing agent_use narrows open sessions too.
+ if ($this->specialRights->mayUse(
+ right: OrganisationSpecialRights::AGENT_USE,
+ userId: $userId,
+ organisationUuid: $agent->getOrganisation()
+ ) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Agent ' . $agentUuid . ' needs the special right agent_use, which this user does not hold'
+ );
+ }
+
return $agent;
}//end loadAgent()
diff --git a/lib/Service/OrganisationService.php b/lib/Service/OrganisationService.php
index 54ec8dce6a..4a66d593e2 100644
--- a/lib/Service/OrganisationService.php
+++ b/lib/Service/OrganisationService.php
@@ -27,6 +27,7 @@
use Exception;
use OCA\OpenRegister\Db\Organisation;
use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IAppConfig;
use OCP\IConfig;
@@ -1101,7 +1102,7 @@ private function addAdminGroupToAuthorization(Organisation $organisation): Organ
}
// Add admin group to special permissions.
- $specialPermissions = ['object_publish', 'agent_use', 'dashboard_view', 'llm_use'];
+ $specialPermissions = OrganisationSpecialRights::OPEN_BY_DEFAULT;
foreach ($specialPermissions as $permission) {
if (($authorization[$permission] ?? null) !== null && is_array($authorization[$permission]) === true) {
if (in_array($adminGroupId, $authorization[$permission], true) === false) {
@@ -1154,7 +1155,7 @@ private function hasAdminGroupInAuthorization(array $authorization): bool {
}
// Check special permissions.
- $specialPermissions = ['object_publish', 'agent_use', 'dashboard_view', 'llm_use'];
+ $specialPermissions = OrganisationSpecialRights::OPEN_BY_DEFAULT;
foreach ($specialPermissions as $permission) {
if (($authorization[$permission] ?? null) !== null && is_array($authorization[$permission]) === true) {
if (in_array($adminGroupId, $authorization[$permission], true) === true) {
diff --git a/lib/Service/Rbac/OrganisationSpecialRights.php b/lib/Service/Rbac/OrganisationSpecialRights.php
index dbb863aea1..cb74c4e98c 100644
--- a/lib/Service/Rbac/OrganisationSpecialRights.php
+++ b/lib/Service/Rbac/OrganisationSpecialRights.php
@@ -4,17 +4,27 @@
* Reads an organisation's special rights, and answers whether a caller holds one.
*
* WHAT A SPECIAL RIGHT IS. An organisation's `authorization` block carries the
- * create, read, update and delete lists per entity type, and beside them a few
- * rights that are not one of those four: `object_publish`, `agent_use`,
- * `dashboard_view` and `llm_use` ({@see \OCA\OpenRegister\Db\Organisation}). The
- * organisation editor shows them in its "Special Rights" tab. Each is a list of
- * the groups that hold it.
+ * create, read, update and delete lists per entity type, and beside them three
+ * rights that are not one of those four: `agent_use`, `llm_use` and
+ * `grant_manage` ({@see \OCA\OpenRegister\Db\Organisation}). The organisation
+ * editor shows them in its "Special Rights" tab. Each is a list of entries.
*
- * WHY THIS CLASS EXISTS. The four rights above are declared, stored and
- * assignable, and nothing reads them: no code path asks whether a caller holds
- * one. This is the read. It is written for the first special right that IS
- * enforced, `grant_manage`, and answers for any right by name so the next one
- * does not get a second reader.
+ * `object_publish` and `dashboard_view` are retired (decisions 247, 248). They
+ * were never enforced. An organisation that still stores them loads as before;
+ * nothing reads or writes them.
+ *
+ * WHY THIS CLASS EXISTS. It is the one read of every special right: a right
+ * over one record, `grant_manage` ({@see holdsOn()}), and the rights a caller
+ * USES, `agent_use` and `llm_use` ({@see mayUse()}).
+ *
+ * A RIGHT A CALLER USES. Read from the organisation the thing being used
+ * belongs to: an agent's organisation for `agent_use`, a conversation's for
+ * `llm_use`. A list of group ids, where `*` stands for every signed-in user.
+ * An administrator always holds it. A key the organisation does not carry is
+ * not narrowed, and holds for everybody: an upgrade must not refuse a caller
+ * nobody decided to refuse. The repair step
+ * {@see \OCA\OpenRegister\Repair\OpenSpecialRightsToEveryone} writes that `*`
+ * into every organisation once, so an administrator sees it and can narrow it.
*
* `grant_manage` lets its holder list, add and remove the grants on a record
* and name the record's owning group, on records the holder does not own. It is
@@ -25,7 +35,7 @@
* groups, so the right cannot be turned into a read.
*
* AN ENTRY. Either a group id, which holds the right on every record of the
- * organisation, as the four existing rights are written. Or an object naming a
+ * organisation, as `agent_use` and `llm_use` are written. Or an object naming a
* group and narrowing it:
*
* {"group": "dossiq-background-service", "register": "dossiq", "schemas": ["reviewBatch"]}
@@ -64,12 +74,20 @@
use OCA\OpenRegister\Db\RegisterMapper;
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Db\SchemaMapper;
+use InvalidArgumentException;
+use OCP\IGroupManager;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* The one reader, and the one in-process writer, of an organisation's special rights.
*
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Decision 239 asks for ONE reader of every special
+ * right; a right over a record and a right a caller
+ * uses are answered here so they cannot drift apart.
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Same reason: the record, register, schema and
+ * organisation lookups plus the group manager.
+ *
* @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
*/
class OrganisationSpecialRights {
@@ -81,6 +99,51 @@ class OrganisationSpecialRights {
*/
public const GRANT_MANAGE = 'grant_manage';
+ /**
+ * The special right to run an agent.
+ *
+ * @var string
+ */
+ public const AGENT_USE = 'agent_use';
+
+ /**
+ * The special right to have a language model called on one's behalf.
+ *
+ * @var string
+ */
+ public const LLM_USE = 'llm_use';
+
+ /**
+ * Every special right an organisation carries, in the order the editor shows them.
+ *
+ * @var string[]
+ */
+ public const ALL = [
+ self::AGENT_USE,
+ self::LLM_USE,
+ self::GRANT_MANAGE,
+ ];
+
+ /**
+ * The rights where `*` means every signed-in user, and a missing key means nobody narrowed it.
+ *
+ * Not `grant_manage`: a right that decides who reads a record is never given
+ * to everybody by a wildcard or by leaving it out.
+ *
+ * @var string[]
+ */
+ public const OPEN_BY_DEFAULT = [
+ self::AGENT_USE,
+ self::LLM_USE,
+ ];
+
+ /**
+ * The entry that gives a right to every signed-in user.
+ *
+ * @var string
+ */
+ public const EVERYONE = '*';
+
/**
* Organisations read per page when giving a right in all of them.
*
@@ -95,12 +158,14 @@ class OrganisationSpecialRights {
* @param RegisterMapper $registers Loads a record's register, for a narrowed entry.
* @param SchemaMapper $schemas Loads a record's schema, for a narrowed entry.
* @param LoggerInterface $logger Logger.
+ * @param IGroupManager $groupManager Says whether a caller is an administrator, and which groups it is in.
*/
public function __construct(
private readonly OrganisationMapper $organisations,
private readonly RegisterMapper $registers,
private readonly SchemaMapper $schemas,
private readonly LoggerInterface $logger,
+ private readonly IGroupManager $groupManager,
) {
}//end __construct()
@@ -146,6 +211,159 @@ public function holdsOn(string $right, array $userGroups, ObjectEntity $object):
return false;
}//end holdsOn()
+ /**
+ * Whether a caller may use something a special right guards, in one organisation.
+ *
+ * For the rights in {@see OPEN_BY_DEFAULT}; any other right answers false.
+ *
+ * - An administrator may.
+ * - The organisation is the one the used thing belongs to. When it names
+ * none, the instance's default organisation is read. When there is no
+ * default either, no organisation could have narrowed the right, and the
+ * caller may: that is how the instance behaved before the right was read.
+ * - An organisation that is named and cannot be loaded refuses.
+ * - A key the organisation does not carry is not narrowed: the caller may.
+ * - A value that is not a list refuses. A list holding `*` admits every
+ * signed-in user. Otherwise the caller must be in one of the listed groups.
+ * An empty list admits only administrators.
+ *
+ * @param string $right The special right's key.
+ * @param string $userId The caller.
+ * @param string|null $organisationUuid The organisation the used thing belongs to, or null.
+ *
+ * @return bool True when the caller may.
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ public function mayUse(string $right, string $userId, ?string $organisationUuid): bool {
+ if ($userId === '' || in_array(needle: $right, haystack: self::OPEN_BY_DEFAULT, strict: true) === false) {
+ return false;
+ }
+
+ if ($this->groupManager->isAdmin($userId) === true) {
+ return true;
+ }
+
+ $entries = $this->entriesInUse(right: $right, organisationUuid: $organisationUuid);
+ if ($entries === null) {
+ return true;
+ }
+
+ return $this->entriesAdmit(entries: $entries, userId: $userId);
+ }//end mayUse()
+
+ /**
+ * Whether a list of a right a caller uses admits the caller: `*`, or one of its groups.
+ *
+ * @param array $entries The stored entries.
+ * @param string $userId The caller.
+ *
+ * @return bool True when it does.
+ */
+ private function entriesAdmit(array $entries, string $userId): bool {
+ foreach ($entries as $entry) {
+ if ($entry === self::EVERYONE) {
+ return true;
+ }
+
+ if (is_string($entry) === true && $entry !== '' && $this->groupManager->isInGroup($userId, $entry) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end entriesAdmit()
+
+ /**
+ * The list a right a caller uses is read from, or null when nothing narrows it.
+ *
+ * @param string $right The special right's key.
+ * @param string|null $organisationUuid The organisation the used thing belongs to, or null.
+ *
+ * @return array|null The stored entries; empty when the organisation
+ * cannot be loaded or the value is not a list.
+ */
+ private function entriesInUse(string $right, ?string $organisationUuid): ?array {
+ $uuid = $organisationUuid;
+ if (is_string($uuid) === false || $uuid === '') {
+ $uuid = $this->organisations->getDefaultOrganisationFromConfig();
+ }
+
+ if (is_string($uuid) === false || $uuid === '') {
+ return null;
+ }
+
+ try {
+ $authorization = $this->organisations->findByUuid($uuid)->getAuthorization();
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: '[OrganisationSpecialRights] Could not load the organisation a special right is read from; refused',
+ context: ['file' => __FILE__, 'line' => __LINE__, 'right' => $right, 'organisation' => $uuid, 'error' => $e->getMessage()]
+ );
+ return [];
+ }
+
+ if (array_key_exists($right, $authorization) === false) {
+ return null;
+ }
+
+ if (is_array($authorization[$right]) === false) {
+ return [];
+ }
+
+ return $authorization[$right];
+ }//end entriesInUse()
+
+ /**
+ * Give every signed-in user a special right in every organisation that does not already.
+ *
+ * For the upgrade that starts reading a right: it writes down what the
+ * instance did before, so nothing is refused until an administrator
+ * narrows it. Adds `*` to the list and keeps whatever groups it names. An
+ * organisation whose list already holds `*` is not written.
+ *
+ * @param string $right The special right's key, one of {@see OPEN_BY_DEFAULT}.
+ *
+ * @return array{organisations: int, changed: int} How many organisations were seen and written.
+ *
+ * @throws InvalidArgumentException When the right may not be given to everybody.
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ public function openToEveryoneInEveryOrganisation(string $right): array {
+ if (in_array(needle: $right, haystack: self::OPEN_BY_DEFAULT, strict: true) === false) {
+ throw new InvalidArgumentException(message: 'The special right ' . $right . ' is never given to everybody');
+ }
+
+ $answer = ['organisations' => 0, 'changed' => 0];
+ $offset = 0;
+ do {
+ $page = $this->organisations->findAll(limit: self::PAGE, offset: $offset);
+ $offset += self::PAGE;
+ $read = count($page);
+ foreach ($page as $organisation) {
+ $answer['organisations']++;
+ $authorization = $organisation->getAuthorization();
+ $entries = [];
+ if (is_array($authorization[$right] ?? null) === true) {
+ $entries = array_values($authorization[$right]);
+ }
+
+ if (in_array(needle: self::EVERYONE, haystack: $entries, strict: true) === true) {
+ continue;
+ }
+
+ $entries[] = self::EVERYONE;
+ $authorization[$right] = $entries;
+ $organisation->setAuthorization($authorization);
+ $this->organisations->update($organisation);
+ $answer['changed']++;
+ }
+ } while ($read === self::PAGE);
+
+ return $answer;
+ }//end openToEveryoneInEveryOrganisation()
+
/**
* Give a group a special right in one organisation, or narrow or widen what it has.
*
diff --git a/openspec/changes/enforce-organisation-special-rights/design.md b/openspec/changes/enforce-organisation-special-rights/design.md
new file mode 100644
index 0000000000..1181504406
--- /dev/null
+++ b/openspec/changes/enforce-organisation-special-rights/design.md
@@ -0,0 +1,165 @@
+# Design: enforce-organisation-special-rights
+
+Read from `development` at 012e1bc9f (after #4601). Line numbers below are in
+this branch unless marked "base".
+
+## Where each right applies
+
+### `agent_use`: running an agent
+
+An agent is run in two places. Both are enforced.
+
+1. **A chat message in a conversation that has an agent.** The agent is loaded
+ and handed to the model call in `ChatService::processMessage()`
+ (`lib/Service/ChatService.php:218`; agent loaded at 246 to 249, model called
+ at 298). Two HTTP entry points reach it:
+ - `POST /api/chat/send` → `ChatController::sendMessage()`
+ (`lib/Controller/ChatController.php:415`, calls processMessage at 453);
+ - `POST /api/chat/stream` → `ChatStreamController::stream()`
+ (`lib/Controller/ChatStreamController.php:218`, calls processMessage at 362).
+ Enforced in `ChatService::processMessage()` at 253 (through
+ `ChatService::missingRight()`, 389), which covers both; and again in
+ `ChatStreamController::stream()` at 302, before the first heartbeat, so the
+ stream answers with its own `forbidden` frame.
+2. **An MCP session that declares an agent** (`agent` initialize parameter or
+ `X-OpenRegister-Agent`). Every call on it acts as that agent.
+ `McpServerController` (`lib/Controller/McpServerController.php`) calls
+ `McpAgentScope::assertUsable()` at 317 (initialize), `filterListing()` at 368
+ (tools/list) and `assertMayCall()` at 427 (tools/call); all three load the
+ agent through `McpAgentScope::loadAgent()`
+ (`lib/Service/Mcp/McpAgentScope.php:235`). Enforced there, at 248 to 258, so
+ narrowing the right also narrows open sessions, as narrowing the grant does.
+
+Not an entry point: an agent-type endpoint
+(`EndpointService::executeAgentEndpoint()`, `lib/Service/EndpointService.php:285`)
+answers 501 and runs nothing (314). A flow's agent principal
+(`lib/Service/Flow/Principal/AgentPrincipalResolver.php`) is an agent acting as
+its own Nextcloud account, not a user running an agent.
+
+Read from: the agent's organisation (`Agent::getOrganisation()`), as
+`grant_manage` is read from the record's.
+
+### `llm_use`: a model called on a user's behalf
+
+Every call to a language model is in `lib/Service/Chat/`:
+`ResponseGenerationHandler::generateResponse()` (`new OpenAIChat` at 391,
+`new OllamaChat` at 433), and in `ConversationManagementHandler` the title
+(238, 260) and the summary (575, 579). All three are reached only from
+`ChatService::processMessage()`: `checkAndSummarize()` at 275,
+`generateResponse()` at 298, `generateConversationTitle()` at 327. The public
+`ChatService::generateConversationTitle()` (423) has no caller in `lib/`.
+So the entry points are the same two as above, and the check is the same one,
+at `lib/Service/ChatService.php:253` and `lib/Controller/ChatStreamController.php:302`.
+
+Not counted: `LlmSettingsController::testChat()` is an administrator's settings
+probe, and administrators pass. Embeddings for vector search
+(`lib/Service/Vectorization/Handlers/EmbeddingGeneratorHandler.php`) call an
+embedding model, not a language model on a user's behalf, and do not need
+`llm_use` (decision 249).
+
+Read from: the conversation's organisation (`Conversation::getOrganisation()`,
+set from the user's active organisation when the conversation is created).
+
+### `object_publish`: no action found; retired (decision 247)
+
+Objects no longer have a publish action. Migration
+`lib/Migration/Version1Date20260313130000.php` dropped `published` and
+`depublished` (110, 119): "The published/depublished metadata system has been
+replaced by RBAC conditional rules with the $now dynamic variable." There is no
+`objects#publish` route and no `setPublished()` on `ObjectEntity`. The nearest
+actions are other things:
+
+- publishing a FILE of an object: `POST /api/objects/{register}/{schema}/{id}/files/batch`
+ (`appinfo/routes.php:1514`) with action `publish` / `depublish`,
+ `lib/Service/File/FileBatchHandler.php:181` and 184, into
+ `FilePublishingHandler::publishFile()` / `unpublishFile()`;
+- making an object readable without an account, by a public access link or a
+ `public` read grant, which is the sharing and RBAC surface.
+
+Not enforced; Ruben retired the right (decision 247).
+
+### `dashboard_view`: nothing to guard; retired (decision 248)
+
+The dashboard is `DashboardController` (`lib/Controller/DashboardController.php`):
+`GET /api/dashboard` (`index()`, 322) and the chart and statistics endpoints
+under `/api/dashboard/`. Every one of them already refuses a non-administrator
+(`isCurrentUserAdmin() === false` → 403, e.g. 323), because the figures are
+instance-wide and would leak across organisations. Administrators pass every
+special right, so enforcing `dashboard_view` there changes nothing. `page()`
+(148) is the app's single-page shell, not the dashboard; refusing it would
+refuse the whole app. The Nextcloud dashboard widget
+(`lib/Dashboard/IntegrationDashboardWidget.php`) lists integrations, not
+organisation analytics.
+
+Not enforced; Ruben retired the right (decision 248).
+
+### Retired rights
+
+`object_publish` and `dashboard_view` are removed from
+`Organisation::getDefaultAuthorization()`, from `OrganisationSpecialRights::ALL`
+and `OPEN_BY_DEFAULT`, from the admin-group backfill in `OrganisationService`,
+from the editor's Special Rights tab and the frontend entity, and from the docs.
+A block that still holds them is stored and served unchanged: `getAuthorization()`
+returns it as is, `mayUse()` answers false for a key it does not know, and no
+code path reads them. Nothing writes them any more; a block that carries them
+is not stripped either.
+
+## Semantics of a right a caller uses
+
+`OrganisationSpecialRights::mayUse(right, userId, organisationUuid)`
+(`lib/Service/Rbac/OrganisationSpecialRights.php:238`):
+
+| Situation | Answer |
+|-----------|--------|
+| Caller is a Nextcloud administrator | allowed |
+| No caller (empty user id) | refused |
+| Right is not `agent_use` or `llm_use` (e.g. `grant_manage`, or a retired right) | refused |
+| Organisation not named on the agent or conversation | the default organisation is read |
+| Not named and no default organisation | allowed: nothing could have narrowed it |
+| Named organisation cannot be loaded | refused (fail closed) |
+| Organisation does not carry the key | allowed: nobody narrowed it |
+| Value is not a list | refused |
+| List holds `*` | every signed-in user, including one in no group |
+| List of groups | a user in one of them |
+| Empty list | administrators only |
+| Entry narrowed to a register/schema (the `grant_manage` shape) | admits nobody here |
+
+A user who is in no organisation is not a special case: the right is read
+from the organisation of the thing being used, never from the caller's
+membership.
+
+## Upgrade
+
+Repair step `OCA\OpenRegister\Repair\OpenSpecialRightsToEveryone`
+(`lib/Repair/OpenSpecialRightsToEveryone.php`), listed under post-migration in
+`appinfo/info.xml`. It adds `*` to `agent_use` and `llm_use` in every
+organisation, keeping any groups
+listed, and never touches `grant_manage`. It runs once: app config
+`openregister/special_rights_opened_to_everyone` records it, so a `*` an
+administrator removes stays removed. If it fails part-way it does not set the
+flag; the next upgrade finishes it and skips organisations that already hold
+`*`. It never throws.
+
+A fresh install needs no step: `Organisation::getDefaultAuthorization()`
+(`lib/Db/Organisation.php`) gives a new organisation `["*"]` under `agent_use`
+and `llm_use`.
+
+## Who changes special rights
+
+`OrganisationController::update()` (`lib/Controller/OrganisationController.php:718`)
+compares each of the three lists (`agent_use`, `llm_use`, `grant_manage`) in the request with the stored one, through
+`changedSpecialRights()` (1098). A key missing on either side counts as its
+default (`["*"]` for `agent_use` and `llm_use`, `[]` for `grant_manage`), because the stored
+block is replaced as a whole. Any difference from a caller for whom
+`OrganisationService::isOrganisationAdmin()` is false (not an administrator,
+not the owner) is a 403 naming the rights. `PATCH` goes through `update()`.
+
+`EditOrganisation.vue` disables the two selects and says why when the signed-in
+user is neither an administrator (`getCurrentUser().isAdmin`) nor the owner;
+creating a new organisation makes you its owner, so it stays editable.
+
+## Decisions on the questions this change raised
+
+- **247 `object_publish`**: retired.
+- **248 `dashboard_view`**: retired.
+- **249 semantic search and `llm_use`**: not needed; search stays as it is.
diff --git a/openspec/changes/enforce-organisation-special-rights/proposal.md b/openspec/changes/enforce-organisation-special-rights/proposal.md
new file mode 100644
index 0000000000..5aaeb92881
--- /dev/null
+++ b/openspec/changes/enforce-organisation-special-rights/proposal.md
@@ -0,0 +1,88 @@
+---
+kind: code
+---
+
+# Proposal: enforce-organisation-special-rights
+
+## Summary
+
+Decision 239 (Ruben, 14 Oct): OpenRegister's special rights `object_publish`,
+`agent_use`, `dashboard_view` and `llm_use` were stored and editable by any
+member and checked nowhere. Enforce each where it applies, and let only an
+administrator or the organisation's owner change who holds a special right.
+
+`agent_use` and `llm_use` name actions this app performs, and are enforced.
+`object_publish` and `dashboard_view` name none (objects have no publish
+action; the dashboard API is administrator only). Ruben retired both
+(decisions 247 and 248). Editing is restricted for the three special rights
+that remain: `agent_use`, `llm_use` and `grant_manage` (#4601).
+
+## What changes
+
+- `OrganisationSpecialRights` (the reader from #4601) gains `mayUse()`, the
+ answer for a right a caller uses, and `openToEveryoneInEveryOrganisation()`,
+ the upgrade writer. No second reader.
+- `agent_use` is checked when a chat message runs an agent
+ (`POST /api/chat/send`, `POST /api/chat/stream`) and when an MCP session acts
+ as an agent (`initialize`, `tools/list`, `tools/call`). It is read from the
+ agent's organisation.
+- `llm_use` is checked on every chat message, before the message is stored or
+ a model is called. It is read from the conversation's organisation.
+- `*` under `agent_use` or `llm_use` means every signed-in user. It is not
+ accepted under `grant_manage`.
+- A new organisation starts with `["*"]` under `agent_use` and `llm_use`.
+- `object_publish` and `dashboard_view` are retired: removed from the
+ organisation's defaults, the editor's Special Rights tab, the repair step and
+ the docs. An organisation that still stores them loads as before; nothing
+ reads them and nothing writes them.
+- Semantic and hybrid search, which embed the query with an embedding model,
+ do not need `llm_use` (decision 249).
+- On upgrade, the repair step `OpenSpecialRightsToEveryone` adds `*` to
+ `agent_use` and `llm_use` in every organisation, once.
+- `PUT`/`PATCH /api/organisations/{uuid}` refuses a change to any special right
+ from anybody but an administrator or the organisation's owner (403, naming
+ the rights). The editor's Special Rights tab is read-only for them, and
+ offers "Everyone" as a choice.
+- `docs/Features/organisations.md` no longer shows the `hasSpecialRight()`
+ call that does not exist.
+
+## Upgrade notes: what becomes refused for whom
+
+Nothing on the day of the upgrade. Before this change every signed-in user
+could chat with the model and run any agent they could see, whatever the lists
+said. The stored lists said otherwise (`[]` on most new organisations,
+`["admin"]` after the admin-group backfill), so enforcing them as stored would
+have refused nearly everybody. The repair step writes `*` beside the stored
+groups in every organisation, which keeps that behaviour.
+
+After the upgrade, a right is narrowed by taking `*` out of its list in the
+Special Rights tab (administrator or owner). From then on:
+
+- a user in none of the listed groups gets 403 from `POST /api/chat/send`, a
+ `forbidden` frame from `POST /api/chat/stream`, and a JSON-RPC error from an
+ MCP `initialize` (or a later call) that names the agent;
+- an administrator is never refused;
+- a later upgrade does not reopen a narrowed right (the step runs once, guarded
+ by app config key `special_rights_opened_to_everyone`).
+
+Members who are not the owner can no longer change `agent_use`, `llm_use` or
+`grant_manage`.
+
+`object_publish` and `dashboard_view` were never enforced, so retiring them
+refuses nobody and grants nobody anything. They disappear from the editor;
+values already stored stay in the organisation's JSON and are ignored.
+
+## What does not change
+
+- Semantic search: it embeds the query and does not need `llm_use`.
+- `grant_manage` keeps the rules from #4601: no wildcard, nobody by default.
+- The create, read, update and delete lists in the organisation's block, and
+ who may edit them.
+- An MCP session that declares no agent keeps the signed-in user's rights.
+
+## Not verified
+
+No live instance was used. The 403 is asserted from the caller's side in unit
+tests that drive the real controller and service over the real reader; both
+controllers are plain `Controller`s, so OCSMiddleware does not rewrite their
+status.
diff --git a/openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md b/openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
new file mode 100644
index 0000000000..cf26d40256
--- /dev/null
+++ b/openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
@@ -0,0 +1,131 @@
+# organisation-special-rights
+
+## ADDED Requirements
+
+### Requirement: Running an agent needs the special right agent_use (REQ-OSR-001)
+
+A caller MUST hold `agent_use` in the agent's organisation to run the agent:
+to send a chat message in a conversation that has the agent
+(`POST /api/chat/send`, `POST /api/chat/stream`) and to act as the agent in an
+MCP session (`initialize`, `tools/list`, `tools/call`). The right MUST be read
+on every call, so narrowing it applies to an open MCP session. A refused chat
+MUST NOT store the message or call a model.
+
+#### Scenario: A holder runs the agent
+- **GIVEN** an agent of organisation `org-agent`, whose `agent_use` lists `ai-users`
+- **WHEN** a user in group `ai-users` sends a chat message in a conversation with that agent
+- **THEN** the message is answered
+
+#### Scenario: A non-holder is refused with 403
+- **GIVEN** the same agent
+- **WHEN** a user in group `staff` only sends a chat message through `POST /api/chat/send`
+- **THEN** the response status is 403 and names `agent_use`
+- **AND** no model is called
+
+#### Scenario: A non-holder cannot open an MCP session as the agent
+- **WHEN** that user sends MCP `initialize` with `agent` set to the agent's uuid
+- **THEN** the answer is a JSON-RPC error naming `agent_use`, and no session is created
+
+### Requirement: Having a language model called needs the special right llm_use (REQ-OSR-002)
+
+A caller MUST hold `llm_use` in the conversation's organisation to send a chat
+message, because every message calls a language model on the caller's behalf
+(the answer, and the title and summary calls made with it). A refused message
+MUST NOT be stored and no model MUST be called. `POST /api/chat/send` MUST
+answer 403. `POST /api/chat/stream`, whose headers are sent first, MUST answer
+with an `error` frame of code `forbidden` naming the right.
+
+#### Scenario: A non-holder is refused before the model is called
+- **GIVEN** a conversation of organisation `org-chat`, whose `llm_use` lists `ai-users`
+- **WHEN** a user in group `staff` only sends a message through `POST /api/chat/send`
+- **THEN** the response status is 403 and names `llm_use`
+- **AND** the message is not stored and no model is called
+
+### Requirement: How a right a caller uses is read (REQ-OSR-003)
+
+For `agent_use` and `llm_use`:
+
+- A Nextcloud administrator MUST always hold the right.
+- The right MUST be read from the organisation the used thing belongs to. When
+ it names none, the instance's default organisation MUST be read. When there
+ is no default organisation either, the right MUST hold.
+- A named organisation that cannot be loaded MUST refuse.
+- An organisation that does not carry the key MUST NOT narrow the right.
+- `*` MUST admit every signed-in user, including one in no group. A list of
+ group ids MUST admit a user in one of them. An empty list MUST admit only
+ administrators. A value that is not a list MUST refuse.
+- `*` MUST NOT be honoured under `grant_manage`, and `grant_manage` MUST NOT
+ be answered by this rule.
+- A new organisation MUST start with `["*"]` under `agent_use` and `llm_use`,
+ and `[]` under `grant_manage`.
+
+#### Scenario: An administrator is never refused
+- **GIVEN** `llm_use` is `[]` in the conversation's organisation
+- **WHEN** an administrator sends a chat message
+- **THEN** the message is answered
+
+#### Scenario: Everyone means every signed-in user
+- **GIVEN** `agent_use` is `["*"]`
+- **WHEN** a user in no group runs the agent
+- **THEN** the user may
+
+### Requirement: The upgrade that starts enforcing refuses nobody (REQ-OSR-004)
+
+On upgrade, a repair step MUST add `*` to `agent_use` and `llm_use` in every
+organisation, keeping the groups already listed, and MUST NOT change
+`grant_manage` or a retired right. It MUST run once, so a `*`
+an administrator removes afterwards stays removed. A failure MUST NOT abort the
+upgrade and MUST leave the step to run again on the next upgrade.
+
+#### Scenario: Every user who could chat before the upgrade still can
+- **GIVEN** organisations storing `llm_use` as `[]`, as `["admin"]` and as `["ai-users"]`
+- **AND** a user in group `staff` only, who could chat before the upgrade
+- **WHEN** the upgrade runs the step registered in `appinfo/info.xml`
+- **THEN** that user may chat in each of them
+- **AND** `["ai-users"]` became `["ai-users", "*"]`
+
+#### Scenario: A narrowed right is not reopened
+- **GIVEN** the step ran, and an administrator then set `llm_use` back to `["ai-users"]`
+- **WHEN** a later upgrade runs
+- **THEN** `llm_use` stays `["ai-users"]`
+
+### Requirement: object_publish and dashboard_view are retired (REQ-OSR-006)
+
+`object_publish` and `dashboard_view` MUST NOT be special rights. They MUST NOT
+be in a new organisation's authorization block, in the organisation editor or
+in what the upgrade writes, and no code path MUST read them. An organisation
+whose stored block still carries them MUST load and be served unchanged.
+
+#### Scenario: An organisation saved before the retirement still loads
+- **GIVEN** an organisation whose stored block holds `object_publish: ["*"]` and `dashboard_view: ["*"]`
+- **WHEN** it is loaded, and a member saves it with those keys left out
+- **THEN** it loads, the save is not refused, and `llm_use` is answered as before
+
+### Requirement: Search does not need llm_use (REQ-OSR-007)
+
+A semantic or hybrid search, which embeds the caller's query with an embedding
+model, MUST NOT require `llm_use` (decision 249). `llm_use` guards calls to a
+language model on the caller's behalf, which happen only in chat.
+
+#### Scenario: A user without llm_use searches
+- **GIVEN** `llm_use` lists `ai-users` and a user in group `staff` only
+- **WHEN** that user runs a semantic search
+- **THEN** the search answers as it did before this change
+
+### Requirement: Only an administrator or the owner changes special rights (REQ-OSR-005)
+
+`PUT` and `PATCH /api/organisations/{uuid}` MUST refuse, with status 403 and
+the keys it would change, a request that would change who holds
+`agent_use`, `llm_use` or `grant_manage`,
+unless the caller is an administrator or the organisation's owner. A key left
+out of the request MUST count as its default. A member MUST still be able to
+edit the rest of the organisation. The organisation editor MUST show the
+Special Rights tab read-only to anybody else, saying why.
+
+#### Scenario: A member narrows llm_use
+- **WHEN** a member who is not the owner sends `llm_use: ["ai-users"]`
+- **THEN** the response status is 403 and names `llm_use`, and nothing is stored
+
+#### Scenario: A member renames the organisation
+- **WHEN** the same member sends a new name and the special rights unchanged
+- **THEN** the organisation is renamed
diff --git a/openspec/changes/enforce-organisation-special-rights/tasks.md b/openspec/changes/enforce-organisation-special-rights/tasks.md
new file mode 100644
index 0000000000..3cbe12ec7b
--- /dev/null
+++ b/openspec/changes/enforce-organisation-special-rights/tasks.md
@@ -0,0 +1,40 @@
+# Tasks: enforce-organisation-special-rights
+
+## 1. Find where each right applies
+- [x] 1.1 `agent_use`: chat with an agent, MCP session as an agent (design.md)
+- [x] 1.2 `llm_use`: every chat message (design.md)
+- [x] 1.3 `object_publish`: no object publish action exists; retired (decision 247)
+- [x] 1.4 `dashboard_view`: the dashboard API is administrator only; retired (decision 248)
+
+## 2. The reader
+- [x] 2.1 `OrganisationSpecialRights::mayUse()`: administrators pass, `*`, missing key, no organisation, load failure
+- [x] 2.2 `OrganisationSpecialRights::openToEveryoneInEveryOrganisation()`, refusing `grant_manage`
+- [x] 2.3 `Organisation::getDefaultAuthorization()` opens `agent_use` and `llm_use`, not `grant_manage`
+- [x] 2.4 Retire `object_publish` and `dashboard_view`: defaults, editor, frontend entity, repair step, admin backfill, docs; a stored block that still holds them loads unchanged
+
+## 3. Enforcement
+- [x] 3.1 `ChatService::processMessage()` checks `llm_use` and `agent_use` before storing or calling a model
+- [x] 3.2 `ChatStreamController::stream()` checks before the first heartbeat and emits `forbidden`
+- [x] 3.3 `McpAgentScope::loadAgent()` checks `agent_use` on initialize and every later call
+
+## 4. Who changes special rights
+- [x] 4.1 `OrganisationController::update()` refuses a change to `agent_use`, `llm_use` or `grant_manage` from a non-owner, non-administrator (403)
+- [x] 4.2 The Special Rights tab is read-only for them, and offers "Everyone"
+
+## 5. Upgrade
+- [x] 5.1 Repair step `OpenSpecialRightsToEveryone`, post-migration, once
+- [x] 5.2 Upgrade notes in proposal.md and docs/Features/organisations.md
+
+## 6. Docs
+- [x] 6.1 Remove the non-existent `hasSpecialRight()` from docs/Features/organisations.md
+
+## 7. Tests
+- [x] 7.1 Per right: holder allowed, non-holder refused with a real status, admin allowed
+- [x] 7.2 The upgrade step, found through info.xml, keeps every current user able
+- [x] 7.3 A member is refused with 403 for each special right; owner and administrator are not
+
+## 8. Open
+- [x] 8.1 `object_publish`: retired (decision 247)
+- [x] 8.2 `dashboard_view`: retired (decision 248)
+- [x] 8.4 Semantic search does not need `llm_use` (decision 249)
+- [ ] 8.3 Live check on a running instance: a narrowed `llm_use` refuses a chat from the widget
diff --git a/src/entities/organisation/organisation.ts b/src/entities/organisation/organisation.ts
index 72c271a826..a09b443678 100644
--- a/src/entities/organisation/organisation.ts
+++ b/src/entities/organisation/organisation.ts
@@ -75,10 +75,8 @@ export class Organisation implements TOrganisation {
agent: { create: [], read: [], update: [], delete: [] },
configuration: { create: [], read: [], update: [], delete: [] },
application: { create: [], read: [], update: [], delete: [] },
- object_publish: [],
- agent_use: [],
- dashboard_view: [],
- llm_use: [],
+ agent_use: ['*'],
+ llm_use: ['*'],
}
this.created = organisation.created || ''
this.updated = organisation.updated || ''
@@ -130,9 +128,7 @@ export class Organisation implements TOrganisation {
agent: crudSchema.optional(),
configuration: crudSchema.optional(),
application: crudSchema.optional(),
- object_publish: z.array(z.string()).optional(),
agent_use: z.array(z.string()).optional(),
- dashboard_view: z.array(z.string()).optional(),
llm_use: z.array(z.string()).optional(),
})
.optional(),
diff --git a/src/entities/organisation/organisation.types.ts b/src/entities/organisation/organisation.types.ts
index 068ca32384..73987d71f5 100644
--- a/src/entities/organisation/organisation.types.ts
+++ b/src/entities/organisation/organisation.types.ts
@@ -68,9 +68,7 @@ export type TOrganisation = {
update?: string[]
delete?: string[]
}
- object_publish?: string[]
agent_use?: string[]
- dashboard_view?: string[]
llm_use?: string[]
}
created?: string
diff --git a/src/modals/organisation/EditOrganisation.vue b/src/modals/organisation/EditOrganisation.vue
index 5b890a19d2..e69d848234 100644
--- a/src/modals/organisation/EditOrganisation.vue
+++ b/src/modals/organisation/EditOrganisation.vue
@@ -1,4 +1,5 @@
@@ -417,6 +418,16 @@ import { navigationStore, organisationStore } from '../../store/store.js'
)
}}
+
+ {{
+ t(
+ 'openregister',
+ 'Only an administrator or the owner of this organisation can change its special rights.',
+ )
+ }}
+
@@ -449,47 +460,6 @@ import { navigationStore, organisationStore } from '../../store/store.js'
-
- |
- object_publish
- |
-
- {{
- t(
- 'openregister',
- 'Publish objects to make them publicly available',
- )
- }}
- |
-
-
- |
-
|
- |
-
-
- |
- dashboard_view
- |
-
- {{
- t(
- 'openregister',
- 'Access organisation dashboard and analytics',
- )
- }}
- |
-
-
@@ -594,10 +526,13 @@ import { navigationStore, organisationStore } from '../../store/store.js'
"
inputLabel="Selected Special Rights Llm Use"
:options="
- filteredAvailableGroups
+ specialRightOptions
"
label="name"
trackBy="id"
+ :disabled="
+ !canEditSpecialRights
+ "
:multiple="true"
:placeholder="
t(
@@ -752,9 +687,7 @@ export default {
loadingGroups: false,
groupSearchDebounce: null,
selectedSpecialRights: {
- object_publish: [],
agent_use: [],
- dashboard_view: [],
llm_use: [],
},
@@ -773,6 +706,37 @@ export default {
},
computed: {
+ /**
+ * Whether the signed-in user may change this organisation's special rights.
+ *
+ * An administrator or the organisation's owner may; so may whoever creates
+ * a new organisation, who becomes its owner. The server refuses anybody
+ * else with a 403, so this only spares them a save that cannot succeed.
+ *
+ * @return {boolean}
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ canEditSpecialRights() {
+ if (!this.organisationItem.uuid) {
+ return true
+ }
+ const user = getNextcloudUser()
+ if (!user) {
+ return false
+ }
+ return user.isAdmin === true || user.uid === this.organisationItem.owner
+ },
+
+ /**
+ * The choices under a special right: every signed-in user, then the groups.
+ *
+ * @return {Array}
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ specialRightOptions() {
+ return [this.everyoneOption(), ...this.filteredAvailableGroups]
+ },
+
/**
* @spec exclude Computed byte-to-MB conversion of the storage quota for display; UI presentation helper.
*/
@@ -1395,10 +1359,20 @@ export default {
}
},
+ /**
+ * The entry `*`, which gives a special right to every signed-in user.
+ *
+ * @return {{id: string, name: string}}
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+ everyoneOption() {
+ return { id: '*', name: t('openregister', 'Everyone') }
+ },
+
/**
* Update special right
*
- * @param {string} right - The special right (object_publish, agent_use, dashboard_view, llm_use)
+ * @param {string} right - The special right (agent_use, llm_use)
* @param {Array} groups - Array of group objects with {id, name}
* @return {void}
* @spec exclude Reactive setter writing a special-right's group IDs to the local authorization model; UI form plumbing.
@@ -1427,18 +1401,16 @@ export default {
*/
initializeSpecialRights() {
const auth = this.organisationItem.authorization || {}
- const specialRightKeys = [
- 'object_publish',
- 'agent_use',
- 'dashboard_view',
- 'llm_use',
- ]
+ const specialRightKeys = ['agent_use', 'llm_use']
specialRightKeys.forEach((right) => {
if (auth[right] && Array.isArray(auth[right])) {
// Map group IDs to group objects
this.selectedSpecialRights[right] = auth[right]
.map((groupId) => {
+ if (groupId === '*') {
+ return this.everyoneOption()
+ }
const group = this.availableGroups.find(
(g) => g.id === groupId,
)
diff --git a/tests/Unit/Controller/ChatStreamControllerTest.php b/tests/Unit/Controller/ChatStreamControllerTest.php
index 6d8b5f18b1..252cb4f54a 100644
--- a/tests/Unit/Controller/ChatStreamControllerTest.php
+++ b/tests/Unit/Controller/ChatStreamControllerTest.php
@@ -30,6 +30,7 @@
use OCA\OpenRegister\Controller\ChatStreamController;
use OCA\OpenRegister\Db\AgentMapper;
+use OCA\OpenRegister\Db\Conversation;
use OCA\OpenRegister\Db\ConversationMapper;
use OCA\OpenRegister\Service\ChatService;
use OCP\IDBConnection;
@@ -81,6 +82,46 @@ protected function emitSseHeaders(): void {
}//end emitSseHeaders()
}//end class
+/**
+ * A php:// stream that serves one request body, so stream() reads a message.
+ */
+class ChatStreamBodyStream {
+
+ /**
+ * @var string
+ */
+ public static string $body = '';
+
+ /**
+ * @var int
+ */
+ private int $pos = 0;
+
+ /**
+ * @var resource|null
+ */
+ public $context;
+
+ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool {
+ $this->pos = 0;
+ return true;
+ }
+
+ public function stream_read(int $count): string {
+ $chunk = substr(self::$body, $this->pos, $count);
+ $this->pos += strlen($chunk);
+ return $chunk;
+ }
+
+ public function stream_eof(): bool {
+ return $this->pos >= strlen(self::$body);
+ }
+
+ public function stream_stat(): array {
+ return [];
+ }
+}//end class
+
class ChatStreamControllerTest extends TestCase {
/**
@@ -227,4 +268,40 @@ public function testNoTokenEventsOnEarlyExitPaths(): void {
);
$this->assertCount(0, $tokenFrames, 'tokens must never be emitted on the unauthenticated path');
}//end testNoTokenEventsOnEarlyExitPaths()
+
+ /**
+ * Decision 239: a caller without llm_use or agent_use gets a `forbidden`
+ * frame naming the right, and the model is never called. The SSE headers
+ * are already sent, so the refusal is a frame, as for a conversation that
+ * is not the caller's.
+ */
+ public function testAMissingSpecialRightEmitsForbiddenBeforeTheModelIsCalled(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $this->userSession->method('getUser')->willReturn($user);
+
+ $conversation = new Conversation();
+ $conversation->setUserId('alice');
+ $conversation->setUuid('conv-1');
+ $this->conversationMapper->method('findByUuid')->willReturn($conversation);
+
+ $this->chatService->expects($this->once())->method('missingRight')
+ ->with($conversation, null, 'alice')
+ ->willReturn('llm_use');
+ $this->chatService->expects($this->never())->method('processMessage');
+
+ stream_wrapper_unregister('php');
+ stream_wrapper_register('php', ChatStreamBodyStream::class);
+ ChatStreamBodyStream::$body = json_encode(['message' => 'Hello', 'conversationUuid' => 'conv-1']);
+ try {
+ $controller = $this->makeController();
+ $this->runStream($controller);
+ } finally {
+ stream_wrapper_restore('php');
+ }
+
+ $this->assertSame('error', $controller->capturedEvents[0]['type']);
+ $this->assertSame('forbidden', $controller->capturedEvents[0]['payload']['code']);
+ $this->assertSame('llm_use', $controller->capturedEvents[0]['payload']['right']);
+ }//end testAMissingSpecialRightEmitsForbiddenBeforeTheModelIsCalled()
}//end class
diff --git a/tests/Unit/Controller/McpAgentLimitsTest.php b/tests/Unit/Controller/McpAgentLimitsTest.php
index a3867b59f9..9ce3f99ec9 100644
--- a/tests/Unit/Controller/McpAgentLimitsTest.php
+++ b/tests/Unit/Controller/McpAgentLimitsTest.php
@@ -27,15 +27,21 @@
use OCA\OpenRegister\Controller\McpServerController;
use OCA\OpenRegister\Db\Agent;
use OCA\OpenRegister\Db\AgentMapper;
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\SchemaMapper;
use OCA\OpenRegister\Mcp\IMcpToolProvider;
use OCA\OpenRegister\Service\Capability\ToolGrantResolver;
use OCA\OpenRegister\Service\Mcp\McpAgentScope;
use OCA\OpenRegister\Service\Mcp\McpProtocolService;
use OCA\OpenRegister\Service\Mcp\McpResourcesService;
use OCA\OpenRegister\Service\Mcp\McpToolsService;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\ICache;
use OCP\ICacheFactory;
+use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
@@ -142,6 +148,18 @@ class McpAgentLimitsTest extends TestCase {
private ?McpServerController $controller = null;
+ /**
+ * What organisation `org-1`, which every agent belongs to, stores under agent_use; null leaves the key out.
+ *
+ * @var list|null
+ */
+ private ?array $agentUse = null;
+
+ /**
+ * Whether alice is an administrator.
+ */
+ private bool $aliceIsAdmin = false;
+
protected function setUp(): void {
parent::setUp();
stream_wrapper_unregister('php');
@@ -204,6 +222,29 @@ static function (string $prefix = '') use (&$caches): ICache {
$logger = new NullLogger();
$tools = new McpToolsService([$provider], $logger);
+
+ // The real reader of special rights (decision 239), over one organisation.
+ $organisations = $this->createMock(OrganisationMapper::class);
+ $organisations->method('findByUuid')->willReturnCallback(function (string $uuid): Organisation {
+ $organisation = new Organisation();
+ $organisation->setUuid($uuid);
+ $authorization = ['object' => ['read' => []]];
+ if ($this->agentUse !== null) {
+ $authorization[OrganisationSpecialRights::AGENT_USE] = $this->agentUse;
+ }
+ $organisation->setAuthorization($authorization);
+ return $organisation;
+ });
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('isAdmin')->willReturnCallback(fn (string $uid): bool => $uid === 'alice' && $this->aliceIsAdmin);
+ $groupManager->method('isInGroup')->willReturnCallback(static fn (string $uid, string $group): bool => $group === 'staff');
+ $specialRights = new OrganisationSpecialRights(
+ $organisations,
+ $this->createMock(RegisterMapper::class),
+ $this->createMock(SchemaMapper::class),
+ $logger,
+ $groupManager
+ );
$this->controller = new McpServerController(
'openregister',
$request,
@@ -212,7 +253,7 @@ static function (string $prefix = '') use (&$caches): ICache {
$this->createMock(McpResourcesService::class),
$logger,
$this->aliceSession(),
- new McpAgentScope($agentMapper, new ToolGrantResolver(), $tools, $cacheFactory, $logger),
+ new McpAgentScope($agentMapper, new ToolGrantResolver(), $tools, $cacheFactory, $logger, $specialRights),
);
}
@@ -241,6 +282,7 @@ private function agent(string $uuid, array $tools, bool $private = false, bool $
$agent->setIsPrivate($private);
$agent->setOwner('bob');
$agent->setActive($active);
+ $agent->setOrganisation('org-1');
$this->agents[$uuid] = $agent;
}
@@ -321,6 +363,51 @@ public function testAnUnknownOrInaccessibleAgentIsRefusedAtInitialize(): void {
}
}
+ public function testAUserWithoutAgentUseIsRefusedAtInitialize(): void {
+ $this->agent('a1', ['openregister.objects.search']);
+ $this->agentUse = ['ai-users'];
+
+ $out = $this->rpc('initialize', ['agent' => 'a1']);
+
+ $this->assertArrayHasKey('error', $out);
+ $this->assertStringContainsString('agent_use', $out['error']['message']);
+ $this->assertSame('', $this->headers['Mcp-Session-Id'], 'a refused agent yields no session');
+ }
+
+ public function testAHolderOfAgentUseActsAsTheAgent(): void {
+ $this->agent('a1', ['openregister.objects.search']);
+ $this->agentUse = ['staff'];
+ $this->rpc('initialize', ['agent' => 'a1']);
+
+ $out = $this->rpc('tools/call', ['name' => 'openregister.objects.search', 'arguments' => []]);
+
+ $this->assertArrayHasKey('result', $out);
+ $this->assertSame(['openregister.objects.search'], $this->ran);
+ }
+
+ public function testAnAdministratorActsAsTheAgentWithoutBeingListed(): void {
+ $this->agent('a1', ['openregister.objects.search']);
+ $this->agentUse = [];
+ $this->aliceIsAdmin = true;
+ $this->rpc('initialize', ['agent' => 'a1']);
+
+ $out = $this->rpc('tools/call', ['name' => 'openregister.objects.search', 'arguments' => []]);
+
+ $this->assertArrayHasKey('result', $out);
+ }
+
+ public function testNarrowingAgentUseAppliesToAnOpenSession(): void {
+ $this->agent('a1', ['openregister.objects.search']);
+ $this->agentUse = ['*'];
+ $this->rpc('initialize', ['agent' => 'a1']);
+ $this->agentUse = ['ai-users'];
+
+ $out = $this->rpc('tools/call', ['name' => 'openregister.objects.search', 'arguments' => []]);
+
+ $this->assertArrayHasKey('error', $out);
+ $this->assertSame([], $this->ran);
+ }
+
public function testASessionWithoutAnAgentKeepsTheUserRights(): void {
$this->rpc('initialize', []);
diff --git a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php
index 093edbc62a..afdb8e47d3 100644
--- a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php
+++ b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php
@@ -105,7 +105,7 @@ public static function changes(): array {
return [
'adding their own group' => [['grant_manage' => ['records-service', 'my-team']]],
'taking it away' => [['grant_manage' => []]],
- 'leaving the key out, which would drop it' => [['object_publish' => ['editors']]],
+ 'leaving the key out, which would drop it' => [['object_publish' => []]],
'widening a narrowed entry' => [['grant_manage' => [['group' => 'records-service']]]],
];
}//end changes()
@@ -113,13 +113,17 @@ public static function changes(): array {
public function testAMemberStillEditsTheRestOfTheBlock(): void {
$this->organisationService->method('isOrganisationAdmin')->willReturn(false);
$this->request->method('getParams')->willReturn(
- ['authorization' => ['grant_manage' => ['records-service'], 'object_publish' => ['editors']]]
+ ['authorization' => [
+ 'grant_manage' => ['records-service'],
+ 'object_publish' => [],
+ 'object' => ['read' => ['editors']],
+ ]]
);
$response = $this->controller->update('org-1');
$this->assertNotSame(403, $response->getStatus());
- $this->assertSame(['editors'], $this->organisation->getAuthorization()['object_publish']);
+ $this->assertSame(['read' => ['editors']], $this->organisation->getAuthorization()['object']);
}//end testAMemberStillEditsTheRestOfTheBlock()
public function testTheOwnerOrAnAdministratorChangesWhoHoldsTheRight(): void {
diff --git a/tests/Unit/Controller/OrganisationSpecialRightsEditTest.php b/tests/Unit/Controller/OrganisationSpecialRightsEditTest.php
new file mode 100644
index 0000000000..58d14ef8e5
--- /dev/null
+++ b/tests/Unit/Controller/OrganisationSpecialRightsEditTest.php
@@ -0,0 +1,209 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://conduction.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Controller;
+
+use OCA\OpenRegister\Controller\OrganisationController;
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Db\TenantUsageMapper;
+use OCA\OpenRegister\Service\OrganisationService;
+use OCA\OpenRegister\Service\TenantLifecycleService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\OCSController;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+
+/**
+ * @covers \OCA\OpenRegister\Controller\OrganisationController
+ * @uses \OCA\OpenRegister\Db\Organisation
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+class OrganisationSpecialRightsEditTest extends TestCase {
+
+ private IRequest&MockObject $request;
+
+ private OrganisationService&MockObject $organisationService;
+
+ private OrganisationMapper&MockObject $organisationMapper;
+
+ private Organisation $organisation;
+
+ private OrganisationController $controller;
+
+ /**
+ * The stored block every test starts from, with the retired `object_publish`
+ * and `dashboard_view` still in it, as an organisation saved before
+ * decisions 247 and 248 has.
+ *
+ * @var array
+ */
+ private const STORED = [
+ 'object' => ['create' => [], 'read' => ['staff'], 'update' => [], 'delete' => []],
+ 'object_publish' => ['*'],
+ 'agent_use' => ['admin', '*'],
+ 'dashboard_view' => ['*'],
+ 'llm_use' => ['ai-users'],
+ 'grant_manage' => [],
+ ];
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->request = $this->createMock(IRequest::class);
+ $this->organisationService = $this->createMock(OrganisationService::class);
+ $this->organisationService->method('hasAccessToOrganisation')->willReturn(true);
+ $this->organisationMapper = $this->createMock(OrganisationMapper::class);
+
+ $this->organisation = new Organisation();
+ $this->organisation->setUuid('org-1');
+ $this->organisation->setName('Gemeente');
+ $this->organisation->setOwner('owner');
+ $this->organisation->setAuthorization(self::STORED);
+ $this->organisationMapper->method('findByUuid')->willReturn($this->organisation);
+ $this->organisationMapper->method('update')->willReturnArgument(0);
+
+ $this->controller = new OrganisationController(
+ 'openregister',
+ $this->request,
+ $this->organisationService,
+ $this->organisationMapper,
+ new NullLogger(),
+ $this->createMock(TenantLifecycleService::class),
+ $this->createMock(TenantUsageMapper::class),
+ $this->createMock(IUserSession::class),
+ $this->createMock(IGroupManager::class)
+ );
+ }//end setUp()
+
+ /**
+ * The stored block with one key replaced.
+ *
+ * @param string $key The key.
+ * @param mixed $value The new value.
+ *
+ * @return array The block.
+ */
+ private static function storedWith(string $key, mixed $value): array {
+ $block = self::STORED;
+ $block[$key] = $value;
+
+ return $block;
+ }//end storedWith()
+
+ /**
+ * @return array, 1: string}>
+ */
+ public static function changes(): array {
+ $withoutLlm = self::STORED;
+ unset($withoutLlm['llm_use']);
+
+ return [
+ 'narrowing agent_use' => [self::storedWith('agent_use', ['admin']), 'agent_use'],
+ 'opening llm_use to everyone' => [self::storedWith('llm_use', ['ai-users', '*']), 'llm_use'],
+ 'leaving llm_use out, which reopens it' => [$withoutLlm, 'llm_use'],
+ 'giving grant_manage' => [self::storedWith('grant_manage', ['my-team']), 'grant_manage'],
+ ];
+ }//end changes()
+
+ /**
+ * @dataProvider changes
+ *
+ * @param array $authorization The block a member sends.
+ * @param string $right The right it changes.
+ */
+ public function testAMemberIsRefusedWith403(array $authorization, string $right): void {
+ $this->organisationService->method('isOrganisationAdmin')->willReturn(false);
+ $this->request->method('getParams')->willReturn(['authorization' => $authorization]);
+ $this->organisationMapper->expects($this->never())->method('update');
+
+ $response = $this->controller->update('org-1');
+
+ $this->assertSame(403, $response->getStatus());
+ $this->assertSame([$right], $response->getData()['rights']);
+ $this->assertSame(self::STORED, $this->organisation->getAuthorization());
+ }//end testAMemberIsRefusedWith403()
+
+ /**
+ * @dataProvider changes
+ *
+ * @param array $authorization The block the owner or an administrator sends.
+ */
+ public function testTheOwnerOrAnAdministratorChangesIt(array $authorization): void {
+ $this->organisationService->method('isOrganisationAdmin')->with('org-1')->willReturn(true);
+ $this->request->method('getParams')->willReturn(['authorization' => $authorization]);
+
+ $response = $this->controller->update('org-1');
+
+ $this->assertNotSame(403, $response->getStatus());
+ $this->assertSame($authorization, $this->organisation->getAuthorization());
+ }//end testTheOwnerOrAnAdministratorChangesIt()
+
+ public function testAMemberStillEditsTheCrudListsAndTheName(): void {
+ $this->organisationService->method('isOrganisationAdmin')->willReturn(false);
+ $block = self::storedWith('object', ['create' => ['staff'], 'read' => ['staff'], 'update' => [], 'delete' => []]);
+ $this->request->method('getParams')->willReturn(['name' => 'Gemeente Zuid', 'authorization' => $block]);
+
+ $response = $this->controller->update('org-1');
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame(['staff'], $this->organisation->getAuthorization()['object']['create']);
+ $this->assertSame('Gemeente Zuid', $this->organisation->getName());
+ }//end testAMemberStillEditsTheCrudListsAndTheName()
+
+ public function testTheRetiredRightsAreNotSpecialRightsAnyMore(): void {
+ $this->organisationService->method('isOrganisationAdmin')->willReturn(false);
+ $block = self::STORED;
+ unset($block['object_publish'], $block['dashboard_view']);
+ $this->request->method('getParams')->willReturn(['authorization' => $block]);
+
+ $response = $this->controller->update('org-1');
+
+ $this->assertSame(200, $response->getStatus());
+ }//end testTheRetiredRightsAreNotSpecialRightsAnyMore()
+
+ public function testLeavingOutAKeyTheOrganisationNeverSetIsNoChange(): void {
+ $this->organisation->setAuthorization(['object' => ['read' => ['staff']]]);
+ $this->organisationService->method('isOrganisationAdmin')->willReturn(false);
+ $this->request->method('getParams')->willReturn(
+ ['authorization' => ['object' => ['read' => ['staff', 'guests']], 'agent_use' => ['*']]]
+ );
+
+ $response = $this->controller->update('org-1');
+
+ $this->assertSame(200, $response->getStatus());
+ }//end testLeavingOutAKeyTheOrganisationNeverSetIsNoChange()
+
+ public function testTheEndpointIsAPlainControllerSoThe403ReachesTheCaller(): void {
+ $this->assertTrue(is_subclass_of(OrganisationController::class, Controller::class));
+ $this->assertFalse(is_subclass_of(OrganisationController::class, OCSController::class));
+ }//end testTheEndpointIsAPlainControllerSoThe403ReachesTheCaller()
+}//end class
diff --git a/tests/Unit/Db/OrganisationTest.php b/tests/Unit/Db/OrganisationTest.php
index ee987cfddc..bb596f6301 100644
--- a/tests/Unit/Db/OrganisationTest.php
+++ b/tests/Unit/Db/OrganisationTest.php
@@ -409,10 +409,11 @@ public function testGetAuthorizationDefault(): void {
$this->assertArrayHasKey('agent', $auth);
$this->assertArrayHasKey('configuration', $auth);
$this->assertArrayHasKey('application', $auth);
- $this->assertArrayHasKey('object_publish', $auth);
$this->assertArrayHasKey('agent_use', $auth);
- $this->assertArrayHasKey('dashboard_view', $auth);
$this->assertArrayHasKey('llm_use', $auth);
+ // Retired, never enforced (decisions 247, 248): not written into a new block.
+ $this->assertArrayNotHasKey('object_publish', $auth);
+ $this->assertArrayNotHasKey('dashboard_view', $auth);
// Each entity type should have CRUD
foreach (['register', 'schema', 'object', 'view', 'agent', 'configuration', 'application'] as $entity) {
@@ -426,11 +427,10 @@ public function testGetAuthorizationDefault(): void {
$this->assertSame([], $auth[$entity]['delete']);
}
- // Special permissions
- $this->assertSame([], $auth['object_publish']);
- $this->assertSame([], $auth['agent_use']);
- $this->assertSame([], $auth['dashboard_view']);
- $this->assertSame([], $auth['llm_use']);
+ // Special rights: open to every signed-in user until narrowed, except grant_manage (decision 239).
+ $this->assertSame(['*'], $auth['agent_use']);
+ $this->assertSame(['*'], $auth['llm_use']);
+ $this->assertSame([], $auth['grant_manage']);
}
public function testSetAuthorizationArray(): void {
diff --git a/tests/Unit/Repair/OpenSpecialRightsToEveryoneTest.php b/tests/Unit/Repair/OpenSpecialRightsToEveryoneTest.php
new file mode 100644
index 0000000000..698be9bc40
--- /dev/null
+++ b/tests/Unit/Repair/OpenSpecialRightsToEveryoneTest.php
@@ -0,0 +1,194 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://conduction.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Repair;
+
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Repair\OpenSpecialRightsToEveryone;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
+use OCP\IAppConfig;
+use OCP\IGroupManager;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use RuntimeException;
+
+/**
+ * @covers \OCA\OpenRegister\Repair\OpenSpecialRightsToEveryone
+ * @uses \OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights
+ * @uses \OCA\OpenRegister\Db\Organisation
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+class OpenSpecialRightsToEveryoneTest extends TestCase {
+
+ /**
+ * Organisations by uuid, as stored.
+ *
+ * @var array
+ */
+ private array $stored = [];
+
+ /**
+ * App config values set during the test.
+ *
+ * @var array
+ */
+ private array $config = [];
+
+ private OrganisationMapper&MockObject $organisations;
+
+ private OrganisationSpecialRights $rights;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ foreach ([
+ 'new' => ['object_publish' => [], 'agent_use' => [], 'dashboard_view' => [], 'llm_use' => [], 'grant_manage' => []],
+ 'backfilled' => ['object_publish' => ['admin'], 'agent_use' => ['admin'], 'dashboard_view' => ['admin'], 'llm_use' => ['admin']],
+ 'narrowed' => ['agent_use' => ['ai-users'], 'llm_use' => ['ai-users'], 'grant_manage' => ['records-service']],
+ ] as $uuid => $block) {
+ $organisation = new Organisation();
+ $organisation->setUuid($uuid);
+ $organisation->setAuthorization($block);
+ $this->stored[$uuid] = $organisation;
+ }
+
+ $this->organisations = $this->createMock(OrganisationMapper::class);
+ $this->organisations->method('findAll')->willReturnCallback(fn (): array => array_values($this->stored));
+ $this->organisations->method('findByUuid')->willReturnCallback(fn (string $uuid): Organisation => $this->stored[$uuid]);
+ $this->organisations->method('update')->willReturnArgument(0);
+
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('isAdmin')->willReturn(false);
+ $groupManager->method('isInGroup')->willReturnCallback(static fn (string $uid, string $group): bool => $group === 'staff');
+
+ $this->rights = new OrganisationSpecialRights(
+ $this->organisations,
+ $this->createMock(RegisterMapper::class),
+ $this->createMock(SchemaMapper::class),
+ new NullLogger(),
+ $groupManager
+ );
+ }//end setUp()
+
+ /**
+ * The class info.xml names, built with its constructor's dependencies.
+ *
+ * @return IRepairStep The step.
+ */
+ private function registeredStep(): IRepairStep {
+ // Read the file with PHP, not libxml: under Nextcloud the libxml external
+ // entity loader refuses file paths, and simplexml_load_file() answers false.
+ $info = simplexml_load_string((string)file_get_contents(dirname(__DIR__, 3) . '/appinfo/info.xml'));
+ $this->assertNotFalse($info);
+
+ $registered = array_map('strval', $info->xpath('//repair-steps/post-migration/step'));
+ $name = OpenSpecialRightsToEveryone::class;
+ $this->assertContains($name, $registered, 'info.xml must run the step on upgrade');
+ $this->assertNotContains($name, array_map('strval', $info->xpath('//repair-steps/install/step')), 'a fresh install already defaults to *');
+
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueBool')->willReturnCallback(
+ fn (string $app, string $key, bool $default = false): bool => $this->config[$key] ?? $default
+ );
+ $appConfig->method('setValueBool')->willReturnCallback(
+ function (string $app, string $key, bool $value): bool {
+ $this->config[$key] = $value;
+ return true;
+ }
+ );
+
+ $step = new $name($this->rights, $appConfig, new NullLogger());
+ $this->assertInstanceOf(IRepairStep::class, $step);
+
+ return $step;
+ }//end registeredStep()
+
+ public function testBeforeTheStepTheStoredListsWouldRefuseAnOrdinaryUser(): void {
+ // The control: without the step, enforcing the lists as stored refuses.
+ foreach (array_keys($this->stored) as $uuid) {
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'bob', $uuid), $uuid);
+ }
+ }//end testBeforeTheStepTheStoredListsWouldRefuseAnOrdinaryUser()
+
+ public function testAfterTheUpgradeEveryUserWhoCouldChatStillCan(): void {
+ $this->registeredStep()->run($this->createMock(IOutput::class));
+
+ foreach (array_keys($this->stored) as $uuid) {
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'bob', $uuid), $uuid);
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::AGENT_USE, 'bob', $uuid), $uuid);
+ }
+
+ $this->assertSame(['ai-users', '*'], $this->stored['narrowed']->getAuthorization()['llm_use'], 'groups are kept');
+ // The retired rights (decisions 247, 248) are left exactly as stored, and not added where absent.
+ $this->assertSame([], $this->stored['new']->getAuthorization()['object_publish']);
+ $this->assertSame(['admin'], $this->stored['backfilled']->getAuthorization()['dashboard_view']);
+ $this->assertArrayNotHasKey('object_publish', $this->stored['narrowed']->getAuthorization());
+ $this->assertSame(['records-service'], $this->stored['narrowed']->getAuthorization()['grant_manage'], 'grant_manage is never opened');
+ $this->assertSame([], $this->stored['new']->getAuthorization()['grant_manage']);
+ $this->assertTrue($this->config[OpenSpecialRightsToEveryone::DONE_KEY]);
+ }//end testAfterTheUpgradeEveryUserWhoCouldChatStillCan()
+
+ public function testALaterUpgradeDoesNotReopenARightAnAdministratorNarrowed(): void {
+ $step = $this->registeredStep();
+ $step->run($this->createMock(IOutput::class));
+
+ $block = $this->stored['narrowed']->getAuthorization();
+ $block['llm_use'] = ['ai-users'];
+ $this->stored['narrowed']->setAuthorization($block);
+
+ $this->organisations->expects($this->never())->method('update');
+ $step->run($this->createMock(IOutput::class));
+
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'bob', 'narrowed'));
+ }//end testALaterUpgradeDoesNotReopenARightAnAdministratorNarrowed()
+
+ public function testAFailureDoesNotAbortTheUpgradeAndIsFinishedNextTime(): void {
+ $organisations = $this->createMock(OrganisationMapper::class);
+ $organisations->method('findAll')->willThrowException(new RuntimeException('database away'));
+ $failing = new OrganisationSpecialRights(
+ $organisations,
+ $this->createMock(RegisterMapper::class),
+ $this->createMock(SchemaMapper::class),
+ new NullLogger(),
+ $this->createMock(IGroupManager::class)
+ );
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueBool')->willReturn(false);
+ $appConfig->expects($this->never())->method('setValueBool');
+ $output = $this->createMock(IOutput::class);
+ $output->expects($this->once())->method('warning');
+
+ (new OpenSpecialRightsToEveryone($failing, $appConfig, new NullLogger()))->run($output);
+ }//end testAFailureDoesNotAbortTheUpgradeAndIsFinishedNextTime()
+}//end class
diff --git a/tests/Unit/Service/ChatServiceTest.php b/tests/Unit/Service/ChatServiceTest.php
index 696097d548..3550cc2d00 100644
--- a/tests/Unit/Service/ChatServiceTest.php
+++ b/tests/Unit/Service/ChatServiceTest.php
@@ -15,6 +15,7 @@
use OCA\OpenRegister\Service\Chat\StreamYieldChannel;
use OCA\OpenRegister\Service\Chat\ToolManagementHandler;
use OCA\OpenRegister\Service\ChatService;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -88,10 +89,21 @@ protected function setUp(): void {
$this->conversationHandler,
$this->historyHandler,
$this->toolHandler,
- $this->logger
+ $this->logger,
+ $this->allRights()
);
}
+ /**
+ * A reader of special rights that lets everybody use everything; ChatSpecialRightsTest covers refusals.
+ */
+ private function allRights(): OrganisationSpecialRights {
+ $rights = $this->createMock(OrganisationSpecialRights::class);
+ $rights->method('mayUse')->willReturn(true);
+
+ return $rights;
+ }
+
// --- processMessage ---
public function testProcessMessageSuccess(): void {
diff --git a/tests/Unit/Service/ChatSpecialRightsTest.php b/tests/Unit/Service/ChatSpecialRightsTest.php
new file mode 100644
index 0000000000..8d7563e625
--- /dev/null
+++ b/tests/Unit/Service/ChatSpecialRightsTest.php
@@ -0,0 +1,284 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://conduction.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service;
+
+use OCA\OpenRegister\Controller\ChatController;
+use OCA\OpenRegister\Db\Agent;
+use OCA\OpenRegister\Db\AgentMapper;
+use OCA\OpenRegister\Db\Conversation;
+use OCA\OpenRegister\Db\ConversationMapper;
+use OCA\OpenRegister\Db\FeedbackMapper;
+use OCA\OpenRegister\Db\Message;
+use OCA\OpenRegister\Db\MessageMapper;
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Chat\ContextRetrievalHandler;
+use OCA\OpenRegister\Service\Chat\ConversationManagementHandler;
+use OCA\OpenRegister\Service\Chat\MessageHistoryHandler;
+use OCA\OpenRegister\Service\Chat\ResponseGenerationHandler;
+use OCA\OpenRegister\Service\Chat\ToolManagementHandler;
+use OCA\OpenRegister\Service\ChatService;
+use OCA\OpenRegister\Service\OrganisationService;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\OCSController;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IL10N;
+use OCP\IRequest;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+
+/**
+ * @covers \OCA\OpenRegister\Service\ChatService
+ * @covers \OCA\OpenRegister\Controller\ChatController
+ * @uses \OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights
+ * @uses \OCA\OpenRegister\Db\Organisation
+ * @uses \OCA\OpenRegister\Db\Conversation
+ * @uses \OCA\OpenRegister\Db\Agent
+ * @uses \OCA\OpenRegister\Db\Message
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+class ChatSpecialRightsTest extends TestCase {
+
+ /**
+ * Authorization blocks per organisation uuid.
+ *
+ * @var array>
+ */
+ private array $blocks = [];
+
+ /**
+ * Group ids per user.
+ *
+ * @var array>
+ */
+ private array $groups = ['alice' => ['ai-users'], 'bob' => ['staff'], 'admin' => ['admin']];
+
+ private ConversationMapper&MockObject $conversations;
+
+ private AgentMapper&MockObject $agents;
+
+ private ResponseGenerationHandler&MockObject $model;
+
+ private MessageHistoryHandler&MockObject $history;
+
+ private ?Agent $agent = null;
+
+ private Conversation $conversation;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->conversation = new Conversation();
+ $this->conversation->setId(1);
+ $this->conversation->setUuid('conv-1');
+ $this->conversation->setTitle('Existing');
+ $this->conversation->setOrganisation('org-chat');
+
+ $this->conversations = $this->createMock(ConversationMapper::class);
+ $this->conversations->method('find')->willReturnCallback(fn (): Conversation => $this->conversation);
+ $this->conversations->method('findByUuid')->willReturnCallback(fn (): Conversation => $this->conversation);
+
+ $this->agents = $this->createMock(AgentMapper::class);
+ $this->agents->method('find')->willReturnCallback(fn (): ?Agent => $this->agent);
+
+ $this->model = $this->createMock(ResponseGenerationHandler::class);
+ $this->history = $this->createMock(MessageHistoryHandler::class);
+ $this->history->method('buildMessageHistory')->willReturn([]);
+ $stored = new Message();
+ $stored->setId(9);
+ $this->history->method('storeMessage')->willReturn($stored);
+ }//end setUp()
+
+ /**
+ * ChatController::sendMessage() as user `$userId`, through the real ChatService and reader.
+ *
+ * @param string $userId The caller.
+ *
+ * @return array{0: int, 1: array} The status and the body the caller gets.
+ */
+ private function send(string $userId): array {
+ $this->conversation->setUserId($userId);
+
+ $organisations = $this->createMock(OrganisationMapper::class);
+ $organisations->method('findByUuid')->willReturnCallback(function (string $uuid): Organisation {
+ $organisation = new Organisation();
+ $organisation->setUuid($uuid);
+ $organisation->setAuthorization($this->blocks[$uuid] ?? []);
+ return $organisation;
+ });
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('isAdmin')->willReturnCallback(static fn (string $uid): bool => $uid === 'admin');
+ $groupManager->method('isInGroup')->willReturnCallback(
+ fn (string $uid, string $group): bool => in_array($group, $this->groups[$uid] ?? [], true)
+ );
+ $rights = new OrganisationSpecialRights(
+ $organisations,
+ $this->createMock(RegisterMapper::class),
+ $this->createMock(SchemaMapper::class),
+ new NullLogger(),
+ $groupManager
+ );
+
+ $context = $this->createMock(ContextRetrievalHandler::class);
+ $context->method('retrieveContext')->willReturn(['text' => '', 'sources' => []]);
+ $messages = $this->createMock(MessageMapper::class);
+ $messages->method('countByConversation')->willReturn(5);
+
+ $service = new ChatService(
+ $this->conversations,
+ $messages,
+ $this->agents,
+ $context,
+ $this->model,
+ $this->createMock(ConversationManagementHandler::class),
+ $this->history,
+ $this->createMock(ToolManagementHandler::class),
+ new NullLogger(),
+ $rights
+ );
+
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturnCallback(
+ static fn (string $key): mixed => ['conversation' => 'conv-1', 'message' => 'Hello'][$key] ?? null
+ );
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnArgument(0);
+
+ $controller = new ChatController(
+ 'openregister',
+ $request,
+ $service,
+ $this->conversations,
+ $messages,
+ $this->createMock(FeedbackMapper::class),
+ $this->agents,
+ $this->createMock(OrganisationService::class),
+ $this->createMock(IDBConnection::class),
+ new NullLogger(),
+ $l10n,
+ $userId
+ );
+
+ $response = $controller->sendMessage();
+
+ return [$response->getStatus(), $response->getData()];
+ }//end send()
+
+ /**
+ * An agent of organisation `org-agent`.
+ *
+ * @return void
+ */
+ private function withAgent(): void {
+ $this->agent = new Agent();
+ $this->agent->setId(5);
+ $this->agent->setOrganisation('org-agent');
+ $this->conversation->setAgentId(5);
+ }//end withAgent()
+
+ public function testTheEndpointIsAPlainControllerSoA403ReachesTheCaller(): void {
+ // OCSMiddleware rewrites the status of an OCSController's response to 200;
+ // it leaves an ordinary Controller's status as it is.
+ $this->assertTrue(is_subclass_of(ChatController::class, Controller::class));
+ $this->assertFalse(is_subclass_of(ChatController::class, OCSController::class));
+ }//end testTheEndpointIsAPlainControllerSoA403ReachesTheCaller()
+
+ public function testAHolderOfLlmUseGetsAnAnswer(): void {
+ $this->blocks['org-chat'] = [OrganisationSpecialRights::LLM_USE => ['ai-users']];
+ $this->model->expects($this->once())->method('generateResponse')->willReturn('Hi');
+
+ [$status, $body] = $this->send('alice');
+
+ $this->assertSame(200, $status);
+ $this->assertSame('Hi', $body['message']);
+ }//end testAHolderOfLlmUseGetsAnAnswer()
+
+ public function testANonHolderOfLlmUseIsRefusedWith403AndNoModelIsCalled(): void {
+ $this->blocks['org-chat'] = [OrganisationSpecialRights::LLM_USE => ['ai-users']];
+ $this->model->expects($this->never())->method('generateResponse');
+ $this->history->expects($this->never())->method('storeMessage');
+
+ [$status, $body] = $this->send('bob');
+
+ $this->assertSame(403, $status);
+ $this->assertStringContainsString('llm_use', $body['message']);
+ }//end testANonHolderOfLlmUseIsRefusedWith403AndNoModelIsCalled()
+
+ public function testAnAdministratorIsNotRefused(): void {
+ $this->blocks['org-chat'] = [OrganisationSpecialRights::LLM_USE => []];
+ $this->withAgent();
+ $this->blocks['org-agent'] = [OrganisationSpecialRights::AGENT_USE => []];
+ $this->model->expects($this->once())->method('generateResponse')->willReturn('Hi');
+
+ [$status] = $this->send('admin');
+
+ $this->assertSame(200, $status);
+ }//end testAnAdministratorIsNotRefused()
+
+ public function testAgentUseIsReadFromTheAgentsOrganisation(): void {
+ $this->blocks['org-chat'] = [OrganisationSpecialRights::LLM_USE => ['*']];
+ $this->withAgent();
+ $this->blocks['org-agent'] = [OrganisationSpecialRights::AGENT_USE => ['ai-users']];
+ $this->model->expects($this->once())->method('generateResponse')->willReturn('Hi');
+
+ [$holder] = $this->send('alice');
+
+ $this->assertSame(200, $holder);
+ }//end testAgentUseIsReadFromTheAgentsOrganisation()
+
+ public function testANonHolderOfAgentUseIsRefusedWith403(): void {
+ $this->blocks['org-chat'] = [OrganisationSpecialRights::LLM_USE => ['*']];
+ $this->withAgent();
+ $this->blocks['org-agent'] = [OrganisationSpecialRights::AGENT_USE => ['ai-users']];
+ $this->model->expects($this->never())->method('generateResponse');
+
+ [$status, $body] = $this->send('bob');
+
+ $this->assertSame(403, $status);
+ $this->assertStringContainsString('agent_use', $body['message']);
+ }//end testANonHolderOfAgentUseIsRefusedWith403()
+
+ public function testAnOrganisationAfterTheUpgradeRefusesNobody(): void {
+ // What OpenSpecialRightsToEveryone leaves behind: `*` beside the backfilled admin group.
+ $this->blocks['org-chat'] = [OrganisationSpecialRights::LLM_USE => ['admin', '*']];
+ $this->withAgent();
+ $this->blocks['org-agent'] = [OrganisationSpecialRights::AGENT_USE => ['admin', '*']];
+ $this->groups['bob'] = [];
+ $this->model->expects($this->once())->method('generateResponse')->willReturn('Hi');
+
+ [$status] = $this->send('bob');
+
+ $this->assertSame(200, $status);
+ }//end testAnOrganisationAfterTheUpgradeRefusesNobody()
+}//end class
diff --git a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php
index 1a4f67f398..0f1ac0cf58 100644
--- a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php
+++ b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php
@@ -129,7 +129,7 @@ static function (string|int $id): Schema {
}
);
- $specialRights = new OrganisationSpecialRights($organisations, $registers, $schemas, new NullLogger());
+ $specialRights = new OrganisationSpecialRights($organisations, $registers, $schemas, new NullLogger(), $this->createMock(IGroupManager::class));
$folder = $this->createMock(Folder::class);
$folder->method('getId')->willReturn(55);
diff --git a/tests/Unit/Service/Rbac/OrganisationSpecialRightsTest.php b/tests/Unit/Service/Rbac/OrganisationSpecialRightsTest.php
index b8c06d2361..d14329dbad 100644
--- a/tests/Unit/Service/Rbac/OrganisationSpecialRightsTest.php
+++ b/tests/Unit/Service/Rbac/OrganisationSpecialRightsTest.php
@@ -29,6 +29,7 @@
use OCA\OpenRegister\Db\SchemaMapper;
use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IGroupManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -69,7 +70,8 @@ protected function setUp(): void {
$this->organisations,
$this->registers,
$this->schemas,
- new NullLogger()
+ new NullLogger(),
+ $this->createMock(IGroupManager::class)
);
}//end setUp()
@@ -175,7 +177,7 @@ public function testAValueThatIsNotAListAdmitsNobody(): void {
public function testASchemaThatCannotBeLoadedAdmitsNobodyThroughANarrowedEntry(): void {
$schemas = $this->createMock(SchemaMapper::class);
$schemas->method('find')->willThrowException(new DoesNotExistException('gone'));
- $rights = new OrganisationSpecialRights($this->organisations, $this->registers, $schemas, new NullLogger());
+ $rights = new OrganisationSpecialRights($this->organisations, $this->registers, $schemas, new NullLogger(), $this->createMock(IGroupManager::class));
$this->organisationWith([['group' => 'svc', 'schemas' => ['reviewBatch']]]);
$this->assertFalse($rights->holdsOn(self::RIGHT, ['svc'], $this->record()));
diff --git a/tests/Unit/Service/Rbac/OrganisationSpecialRightsUseTest.php b/tests/Unit/Service/Rbac/OrganisationSpecialRightsUseTest.php
new file mode 100644
index 0000000000..86e3406bad
--- /dev/null
+++ b/tests/Unit/Service/Rbac/OrganisationSpecialRightsUseTest.php
@@ -0,0 +1,242 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://conduction.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service\Rbac;
+
+use InvalidArgumentException;
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IGroupManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+
+/**
+ * @covers \OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights
+ * @uses \OCA\OpenRegister\Db\Organisation
+ *
+ * @spec openspec/changes/enforce-organisation-special-rights/specs/organisation-special-rights/spec.md
+ */
+class OrganisationSpecialRightsUseTest extends TestCase {
+
+ private OrganisationMapper&MockObject $organisations;
+
+ private IGroupManager&MockObject $groupManager;
+
+ private OrganisationSpecialRights $rights;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->organisations = $this->createMock(OrganisationMapper::class);
+ $this->groupManager = $this->createMock(IGroupManager::class);
+
+ $this->groupManager->method('isAdmin')->willReturnCallback(static fn (string $uid): bool => $uid === 'admin');
+ $this->groupManager->method('isInGroup')->willReturnCallback(
+ static fn (string $uid, string $group): bool => in_array($group, ['alice' => ['ai-users'], 'bob' => ['staff']][$uid] ?? [], true)
+ );
+
+ $this->rights = new OrganisationSpecialRights(
+ $this->organisations,
+ $this->createMock(RegisterMapper::class),
+ $this->createMock(SchemaMapper::class),
+ new NullLogger(),
+ $this->groupManager
+ );
+ }//end setUp()
+
+ /**
+ * Organisation `org-1` with one authorization block.
+ *
+ * @param array $authorization The stored block.
+ *
+ * @return Organisation The organisation.
+ */
+ private function organisationWith(array $authorization): Organisation {
+ $organisation = new Organisation();
+ $organisation->setUuid('org-1');
+ $organisation->setAuthorization($authorization);
+ $this->organisations->method('findByUuid')->with('org-1')->willReturn($organisation);
+
+ return $organisation;
+ }//end organisationWith()
+
+ /**
+ * @return array
+ */
+ public static function rightsInUse(): array {
+ return [
+ 'agent_use' => [OrganisationSpecialRights::AGENT_USE],
+ 'llm_use' => [OrganisationSpecialRights::LLM_USE],
+ ];
+ }//end rightsInUse()
+
+ /**
+ * @dataProvider rightsInUse
+ */
+ public function testAHolderMay(string $right): void {
+ $this->organisationWith([$right => ['ai-users']]);
+
+ $this->assertTrue($this->rights->mayUse($right, 'alice', 'org-1'));
+ }//end testAHolderMay()
+
+ /**
+ * @dataProvider rightsInUse
+ */
+ public function testANonHolderMayNot(string $right): void {
+ $this->organisationWith([$right => ['ai-users']]);
+
+ $this->assertFalse($this->rights->mayUse($right, 'bob', 'org-1'));
+ $this->assertFalse($this->rights->mayUse($right, 'carol', 'org-1'), 'a user in no group');
+ }//end testANonHolderMayNot()
+
+ /**
+ * @dataProvider rightsInUse
+ */
+ public function testAnAdministratorMayWithoutBeingListed(string $right): void {
+ $this->organisationWith([$right => []]);
+
+ $this->assertTrue($this->rights->mayUse($right, 'admin', 'org-1'));
+ }//end testAnAdministratorMayWithoutBeingListed()
+
+ /**
+ * @dataProvider rightsInUse
+ */
+ public function testAnEmptyListAdmitsOnlyAdministrators(string $right): void {
+ $this->organisationWith([$right => []]);
+
+ $this->assertFalse($this->rights->mayUse($right, 'alice', 'org-1'));
+ }//end testAnEmptyListAdmitsOnlyAdministrators()
+
+ /**
+ * @dataProvider rightsInUse
+ */
+ public function testEveryoneAdmitsEverySignedInUserIncludingOneInNoGroup(string $right): void {
+ $this->organisationWith([$right => ['ai-users', '*']]);
+
+ $this->assertTrue($this->rights->mayUse($right, 'bob', 'org-1'));
+ $this->assertTrue($this->rights->mayUse($right, 'carol', 'org-1'));
+ $this->assertFalse($this->rights->mayUse($right, '', 'org-1'), 'nobody signed in');
+ }//end testEveryoneAdmitsEverySignedInUserIncludingOneInNoGroup()
+
+ /**
+ * @dataProvider rightsInUse
+ */
+ public function testAKeyTheOrganisationNeverSetIsNotNarrowed(string $right): void {
+ $this->organisationWith(['object' => ['read' => ['staff']]]);
+
+ $this->assertTrue($this->rights->mayUse($right, 'bob', 'org-1'));
+ }//end testAKeyTheOrganisationNeverSetIsNotNarrowed()
+
+ public function testAValueThatIsNotAListRefuses(): void {
+ $this->organisationWith([OrganisationSpecialRights::LLM_USE => 'ai-users']);
+
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'alice', 'org-1'));
+ }//end testAValueThatIsNotAListRefuses()
+
+ public function testANarrowedEntryIsNotAGroupForARightInUse(): void {
+ $this->organisationWith([OrganisationSpecialRights::AGENT_USE => [['group' => 'ai-users', 'register' => 'r']]]);
+
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::AGENT_USE, 'alice', 'org-1'));
+ }//end testANarrowedEntryIsNotAGroupForARightInUse()
+
+ public function testNoOrganisationNamedReadsTheDefaultOrganisation(): void {
+ $this->organisationWith([OrganisationSpecialRights::LLM_USE => ['ai-users']]);
+ $this->organisations->method('getDefaultOrganisationFromConfig')->willReturn('org-1');
+
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'alice', null));
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'bob', ''));
+ }//end testNoOrganisationNamedReadsTheDefaultOrganisation()
+
+ public function testNoOrganisationAndNoDefaultLeavesTheRightUnnarrowed(): void {
+ $this->organisations->method('getDefaultOrganisationFromConfig')->willReturn(null);
+ $this->organisations->expects($this->never())->method('findByUuid');
+
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'bob', null));
+ }//end testNoOrganisationAndNoDefaultLeavesTheRightUnnarrowed()
+
+ public function testAnOrganisationThatCannotBeLoadedRefuses(): void {
+ $this->organisations->method('findByUuid')->willThrowException(new DoesNotExistException('gone'));
+
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'bob', 'org-gone'));
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'admin', 'org-gone'));
+ }//end testAnOrganisationThatCannotBeLoadedRefuses()
+
+ public function testGrantManageIsNeverAnsweredAsARightInUse(): void {
+ $this->organisationWith([OrganisationSpecialRights::GRANT_MANAGE => ['*', 'ai-users']]);
+
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::GRANT_MANAGE, 'alice', 'org-1'));
+ $this->assertFalse($this->rights->mayUse(OrganisationSpecialRights::GRANT_MANAGE, 'admin', 'org-1'));
+ }//end testGrantManageIsNeverAnsweredAsARightInUse()
+
+ public function testARetiredRightStillStoredLoadsAndIsNotAnswered(): void {
+ $this->organisationWith(['object_publish' => ['*'], 'dashboard_view' => ['*'], OrganisationSpecialRights::LLM_USE => ['ai-users']]);
+
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'alice', 'org-1'));
+ $this->assertFalse($this->rights->mayUse('object_publish', 'alice', 'org-1'));
+ $this->assertFalse($this->rights->mayUse('dashboard_view', 'alice', 'org-1'));
+ }//end testARetiredRightStillStoredLoadsAndIsNotAnswered()
+
+ public function testANewOrganisationOpensTheRightsInUseAndNotGrantManage(): void {
+ $organisation = new Organisation();
+ $organisation->setUuid('org-1');
+ $this->organisations->method('findByUuid')->willReturn($organisation);
+
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::AGENT_USE, 'carol', 'org-1'));
+ $this->assertTrue($this->rights->mayUse(OrganisationSpecialRights::LLM_USE, 'carol', 'org-1'));
+ $this->assertSame([], $organisation->getAuthorization()[OrganisationSpecialRights::GRANT_MANAGE]);
+ $this->assertArrayNotHasKey('object_publish', $organisation->getAuthorization());
+ $this->assertArrayNotHasKey('dashboard_view', $organisation->getAuthorization());
+ }//end testANewOrganisationOpensTheRightsInUseAndNotGrantManage()
+
+ public function testOpeningToEveryoneAddsTheWildcardAndKeepsTheGroups(): void {
+ $narrowed = new Organisation();
+ $narrowed->setUuid('org-1');
+ $narrowed->setAuthorization([OrganisationSpecialRights::LLM_USE => ['admin'], 'object' => ['read' => ['staff']]]);
+ $open = new Organisation();
+ $open->setUuid('org-2');
+ $open->setAuthorization([OrganisationSpecialRights::LLM_USE => ['*']]);
+
+ $this->organisations->method('findAll')->willReturn([$narrowed, $open]);
+ $this->organisations->expects($this->once())->method('update')->with($narrowed)->willReturnArgument(0);
+
+ $answer = $this->rights->openToEveryoneInEveryOrganisation(OrganisationSpecialRights::LLM_USE);
+
+ $this->assertSame(['organisations' => 2, 'changed' => 1], $answer);
+ $this->assertSame(['admin', '*'], $narrowed->getAuthorization()[OrganisationSpecialRights::LLM_USE]);
+ $this->assertSame(['read' => ['staff']], $narrowed->getAuthorization()['object']);
+ }//end testOpeningToEveryoneAddsTheWildcardAndKeepsTheGroups()
+
+ public function testGrantManageIsNeverOpenedToEveryone(): void {
+ $this->organisations->expects($this->never())->method('update');
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->rights->openToEveryoneInEveryOrganisation(OrganisationSpecialRights::GRANT_MANAGE);
+ }//end testGrantManageIsNeverOpenedToEveryone()
+}//end class
|