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