diff --git a/appinfo/routes.php b/appinfo/routes.php index c0605f99..742a7568 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -220,6 +220,14 @@ ['name' => 'merge#dryRun', 'url' => '/api/organisaties/{uuid}/merge/dry-run', 'verb' => 'POST'], ['name' => 'merge#execute', 'url' => '/api/organisaties/{uuid}/merge', 'verb' => 'POST'], + // SELF-SERVICE COLLEAGUE ACCESS (multi-org-membership) — beheerder-of-this- + // organisation gated (authorizeBeheerder() guard in the controller body, + // no-admin-idor safe). Delegates the actual membership mutation to + // OpenRegister's OrganisationService::joinOrganisation()/leaveOrganisation(). + // @spec openspec/specs/multi-org-membership/spec.md#requirement-granting-or-revoking-organisation-access-must-be-restricted-to-a-beheerder-of-that-organisation-req-004 + ['name' => 'organisationMembers#grant', 'url' => '/api/organisations/{uuid}/members', 'verb' => 'POST'], + ['name' => 'organisationMembers#revoke', 'url' => '/api/organisations/{uuid}/members/{userId}', 'verb' => 'DELETE'], + // FEDERATION SETTINGS / MANUAL PULL — admin-gated (AuthorizedAdminSetting). ['name' => 'federation#status', 'url' => '/api/federation/status', 'verb' => 'GET'], ['name' => 'federation#addPeer', 'url' => '/api/federation/peers', 'verb' => 'POST'], diff --git a/docs/features/multi-org-membership.md b/docs/features/multi-org-membership.md new file mode 100644 index 00000000..069c3eab --- /dev/null +++ b/docs/features/multi-org-membership.md @@ -0,0 +1,104 @@ + + +# Multi-organisation membership + +Lets one Nextcloud account act for more than one organisation: an +organisation switcher in the app header, and a self-service flow so an +organisation's own beheerders can grant or revoke an existing colleague's +access — without an administrator. Built for shared service centres, +samenwerkingsverbanden, and the dual-membership transition period during a +gemeentelijke herindeling. See +[VNG Softwarecatalogus issue #57](https://github.com/VNG-Realisatie/Softwarecatalogus/issues/57), +[#60](https://github.com/VNG-Realisatie/Softwarecatalogus/issues/60), and +[#65](https://github.com/VNG-Realisatie/Softwarecatalogus/issues/65). + +Specification: [`openspec/specs/multi-org-membership/spec.md`](../../openspec/specs/multi-org-membership/spec.md). + +Everything in this feature is built on OpenRegister's own, already-shipped +`OrganisationService`/`OrganisationController` — SoftwareCatalog does not +store a separate membership record anywhere. + +## Switching your active organisation + +A member of two or more organisations sees a switcher in the app header +(next to the other header actions), showing the currently-active +organisation's name. Opening it lists every organisation the user belongs +to; picking a different one: + +1. Calls OpenRegister's own `POST /apps/openregister/api/organisations/{uuid}/set-active` + directly — SoftwareCatalog does not proxy this call. +2. OpenRegister verifies, server-side, that the caller is actually a member + of that organisation (`Organisation::hasUser()`) before changing + anything. A switch to an organisation the user does not belong to is + refused and the active organisation is left unchanged — this can never + be bypassed by anything the client sends. +3. On success, the page reloads. Every list, dashboard, and detail view + therefore re-fetches from scratch under the new active organisation's + session — there is no risk of one view still showing the previous + organisation's data after a switch. + +A user who belongs to zero or one organisation does not see a switch-target +list (mirrors the shared `CnTenantBadge` component's auto-hide behaviour), +though the header entry still shows their one organisation's name. + +## Granting or revoking a colleague's access + +A **beheerder** — a member of the organisation who also holds the +`beheerder` Nextcloud group role — sees a "Manage members" entry in the same +header switcher, opening a dialog for their currently-active organisation: + +- **Current members** — the organisation's member list, read directly from + OpenRegister's own `GET /apps/openregister/api/organisations/{uuid}` + (already access-controlled there). +- **Grant access** — pick an *existing* Nextcloud user (via `NcSelectUsers`) + and confirm. This is deliberately existing-user-only: it is not an invite + flow, and never creates a Nextcloud account. +- **Revoke access** — remove a member from the list. + +Both actions are authorised server-side by a new, SoftwareCatalog-specific +check (`OrganisationMembersController::authorizeBeheerder()`) that OpenRegister's +own membership endpoints don't perform (OpenRegister's `join`/`leave` only +recognise a Nextcloud admin or the organisation's single `owner` field as +allowed to manage another user's membership — SoftwareCatalog's `beheerder` +role is a separate, broader concept). The check requires **both**: + +1. The caller is authenticated and in the global `beheerder` Nextcloud group. +2. The caller's own organisation memberships — resolved from OpenRegister's + `OrganisationService::getUserOrganisations()`, never from anything the + client sends — include the organisation being managed. + +Only once both hold does the controller call OpenRegister's own +`joinOrganisation()` / `leaveOrganisation()` to perform the actual mutation. +A beheerder of one organisation cannot grant or revoke access to a +*different* organisation they don't belong to, even though they hold the +role. + +``` +POST /apps/softwarecatalog/api/organisations/{uuid}/members +{ "userId": "j.devries" } + +DELETE /apps/softwarecatalog/api/organisations/{uuid}/members/{userId} +``` + +## What this does not do + +- **No new-user invites.** Granting access always requires an existing + Nextcloud account; inviting someone by e-mail is a separate, unbuilt + capability (user provisioning). +- **No change to what a role can see.** Switching the active organisation + changes *which* organisation a session is scoped to; the RBAC rules that + govern what each role may read within that organisation are unchanged + (`vendor-visibility-rbac`). +- **No cross-organisation data merge.** That is a different capability + ([organisation merge](organisation-merge.md), softwarecatalog#370). + +## Screenshots + +Not captured in this pass — this change did not deploy to a live instance +(the shared dev Nextcloud container was deliberately left untouched per the +change's constraints). A follow-up pass should capture the header switcher +and the "Manage members" dialog via Playwright MCP against a running +instance with a multi-organisation test user, consistent with ADR-010. diff --git a/l10n/en.json b/l10n/en.json index da5b380d..e0e27494 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -568,6 +568,14 @@ "Suite created.": "Suite created.", "Suites": "Suites", "Version": "Version", - "Vulnerability match": "Vulnerability match" + "Vulnerability match": "Vulnerability match", + "Current members": "Current members", + "Failed to switch organisation": "Failed to switch organisation", + "Grant access": "Grant access", + "Grant access to an existing Nextcloud user": "Grant access to an existing Nextcloud user", + "Manage access to {name}": "Manage access to {name}", + "Manage members": "Manage members", + "No members yet.": "No members yet.", + "Revoke access": "Revoke access" } } diff --git a/l10n/en_US.js b/l10n/en_US.js index 852a4b57..e160e615 100644 --- a/l10n/en_US.js +++ b/l10n/en_US.js @@ -404,7 +404,16 @@ OC.L10N.register( "What was your experience with this software?" : "What was your experience with this software?", "Submit review" : "Submit review", "Thank you — your review was submitted for moderation" : "Thank you — your review was submitted for moderation", - "Could not submit your review" : "Could not submit your review" + "Could not submit your review" : "Could not submit your review", + "Failed to switch organisation" : "Failed to switch organisation", + "Manage members" : "Manage members", + "Manage access to {name}" : "Manage access to {name}", + "Grant access to an existing Nextcloud user" : "Grant access to an existing Nextcloud user", + "Grant access" : "Grant access", + "Current members" : "Current members", + "No members yet." : "No members yet.", + "Revoke access" : "Revoke access", + "Close" : "Close" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/en_US.json b/l10n/en_US.json index dc284eff..8fee0daa 100644 --- a/l10n/en_US.json +++ b/l10n/en_US.json @@ -463,6 +463,15 @@ "What was your experience with this software?": "What was your experience with this software?", "Submit review": "Submit review", "Thank you — your review was submitted for moderation": "Thank you — your review was submitted for moderation", - "Could not submit your review": "Could not submit your review" + "Could not submit your review": "Could not submit your review", + "Failed to switch organisation": "Failed to switch organisation", + "Manage members": "Manage members", + "Manage access to {name}": "Manage access to {name}", + "Grant access to an existing Nextcloud user": "Grant access to an existing Nextcloud user", + "Grant access": "Grant access", + "Current members": "Current members", + "No members yet.": "No members yet.", + "Revoke access": "Revoke access", + "Close": "Close" } } diff --git a/l10n/nl.js b/l10n/nl.js index dc6a3624..bc03496e 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -441,7 +441,16 @@ OC.L10N.register( "What was your experience with this software?" : "Wat was uw ervaring met deze software?", "Submit review" : "Beoordeling indienen", "Thank you — your review was submitted for moderation" : "Bedankt — uw beoordeling is ingediend ter moderatie", - "Could not submit your review" : "Kon uw beoordeling niet indienen" + "Could not submit your review" : "Kon uw beoordeling niet indienen", + "Failed to switch organisation" : "Wisselen van organisatie is mislukt", + "Manage members" : "Leden beheren", + "Manage access to {name}" : "Toegang tot {name} beheren", + "Grant access to an existing Nextcloud user" : "Toegang geven aan een bestaande Nextcloud-gebruiker", + "Grant access" : "Toegang geven", + "Current members" : "Huidige leden", + "No members yet." : "Nog geen leden.", + "Revoke access" : "Toegang intrekken", + "Close" : "Sluiten" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nl.json b/l10n/nl.json index 99f5152f..3e3bd319 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -607,6 +607,15 @@ "What was your experience with this software?": "Wat was uw ervaring met deze software?", "Submit review": "Beoordeling indienen", "Thank you — your review was submitted for moderation": "Bedankt — uw beoordeling is ingediend ter moderatie", - "Could not submit your review": "Kon uw beoordeling niet indienen" + "Could not submit your review": "Kon uw beoordeling niet indienen", + "Failed to switch organisation": "Wisselen van organisatie is mislukt", + "Manage members": "Leden beheren", + "Manage access to {name}": "Toegang tot {name} beheren", + "Grant access to an existing Nextcloud user": "Toegang geven aan een bestaande Nextcloud-gebruiker", + "Grant access": "Toegang geven", + "Current members": "Huidige leden", + "No members yet.": "Nog geen leden.", + "Revoke access": "Toegang intrekken", + "Close": "Sluiten" } } diff --git a/lib/Controller/ContactpersonenController.php b/lib/Controller/ContactpersonenController.php index cd66e436..cb2e7e24 100644 --- a/lib/Controller/ContactpersonenController.php +++ b/lib/Controller/ContactpersonenController.php @@ -1467,6 +1467,7 @@ public function getMe(): JSONResponse // Initialize response with user data from Nextcloud. $response = $this->buildEmptyMeResponse(userEmail: $userEmail); + $response['isBeheerder'] = $this->groupManager->isInGroup($userId, 'beheerder'); // Try to get contactpersoon data for additional profile info. $this->enrichMeWithContactpersoonData( @@ -1573,6 +1574,14 @@ private function buildEmptyMeResponse(string $userEmail): array 'active' => null, 'all' => [], ], + // Global `beheerder` NC group membership — a client-side hint + // only (per-organisation authorization is re-verified + // server-side on every grant/revoke by + // OrganisationMembersController; this flag merely lets the + // frontend decide whether to render the "manage members" + // affordance at all). + // See @spec openspec/specs/multi-org-membership/spec.md#requirement-granting-or-revoking-organisation-access-must-be-restricted-to-a-beheerder-of-that-organisation-req-004 for the authorization contract this flag hints at. + 'isBeheerder' => false, ]; }//end buildEmptyMeResponse() diff --git a/lib/Controller/OrganisationMembersController.php b/lib/Controller/OrganisationMembersController.php new file mode 100644 index 00000000..2ab99267 --- /dev/null +++ b/lib/Controller/OrganisationMembersController.php @@ -0,0 +1,275 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/multi-org-membership/spec.md#requirement-granting-or-revoking-organisation-access-must-be-restricted-to-a-beheerder-of-that-organisation-req-004 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Controller; + +use OCA\SoftwareCatalog\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserManager; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Beheerder-gated grant/revoke of organisation membership for an existing + * Nextcloud user, delegating the actual mutation to OpenRegister. + * + * @spec openspec/specs/multi-org-membership/spec.md + */ +class OrganisationMembersController extends Controller +{ + /** + * NC group whose members may manage an organisation's own membership, + * per organisation, when they also belong to that organisation. + * Matches the group `ContactPersonHandler::assignBeheerderRole()` + * already assigns. + */ + private const BEHEERDER_GROUP = 'beheerder'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param IUserSession $userSession The user session (auth guard). + * @param IGroupManager $groupManager Group membership (beheerder guard). + * @param IUserManager $userManager User lookup (existing-user-only guard). + * @param ContainerInterface $container DI container, used to reach OpenRegister's + * `OrganisationService` without a hard compile-time + * dependency on another app's class. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + IRequest $request, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly IUserManager $userManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Grant an existing Nextcloud user access to an organisation. + * + * @param string $uuid The organisation UUID. + * @param string $userId The existing Nextcloud user id to grant access to. + * + * @return JSONResponse Success (200), or a 401/403/404/400 error. + * + * @NoAdminRequired + * @NoCSRFRequired + * @spec openspec/specs/multi-org-membership/spec.md#requirement-granting-access-must-only-target-an-existing-nextcloud-user-req-005 + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function grant(string $uuid, string $userId): JSONResponse + { + $guard = $this->authorizeBeheerder(organisationUuid: $uuid); + if ($guard instanceof JSONResponse) { + return $guard; + } + + if ($this->userManager->get($userId) === null) { + return new JSONResponse( + data: ['error' => 'User not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $organisationService = $this->getOrganisationService(); + $organisationService->joinOrganisation(organisationUuid: $uuid, targetUserId: $userId); + } catch (\Exception $e) { + $this->logger->error( + '[OrganisationMembersController] Failed to grant organisation access', + ['organisationUuid' => $uuid, 'userId' => $userId, 'error' => $e->getMessage()] + ); + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse( + data: [ + 'message' => 'Successfully granted organisation access', + 'userId' => $userId, + ], + statusCode: Http::STATUS_OK + ); + }//end grant() + + /** + * Revoke an existing member's access to an organisation. + * + * @param string $uuid The organisation UUID. + * @param string $userId The Nextcloud user id to revoke access from. + * + * @return JSONResponse Success (200), or a 401/403/400 error. + * + * @NoAdminRequired + * @NoCSRFRequired + * @spec openspec/specs/multi-org-membership/spec.md#requirement-granting-or-revoking-organisation-access-must-be-restricted-to-a-beheerder-of-that-organisation-req-004 + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function revoke(string $uuid, string $userId): JSONResponse + { + $guard = $this->authorizeBeheerder(organisationUuid: $uuid); + if ($guard instanceof JSONResponse) { + return $guard; + } + + try { + $organisationService = $this->getOrganisationService(); + $organisationService->leaveOrganisation(organisationUuid: $uuid, targetUserId: $userId); + } catch (\Exception $e) { + $this->logger->error( + '[OrganisationMembersController] Failed to revoke organisation access', + ['organisationUuid' => $uuid, 'userId' => $userId, 'error' => $e->getMessage()] + ); + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse( + data: [ + 'message' => 'Successfully revoked organisation access', + 'userId' => $userId, + ], + statusCode: Http::STATUS_OK + ); + }//end revoke() + + /** + * Beheerder-of-this-organisation authorization guard (IDOR guard). + * + * Fail-closed, two independent server-side checks — never a + * client-supplied claim: + * 1. The caller is authenticated and belongs to the global `beheerder` + * NC group (cheap check first; rejects most non-beheerders without + * an OpenRegister round trip). + * 2. The caller's OWN organisation memberships, resolved from + * OpenRegister's `OrganisationService::getUserOrganisations()` + * (session-scoped — the same source `setActiveOrganisation()` + * trusts), include the TARGET organisation UUID. A `beheerder` of a + * different organisation does NOT pass this check. + * + * @param string $organisationUuid The organisation the caller is trying to manage. + * + * @return JSONResponse|null Error response, or null when authorized. + * + * @spec openspec/specs/multi-org-membership/spec.md#requirement-granting-or-revoking-organisation-access-must-be-restricted-to-a-beheerder-of-that-organisation-req-004 + */ + private function authorizeBeheerder(string $organisationUuid): ?JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['error' => 'Not authenticated'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + if ($this->groupManager->isInGroup($user->getUID(), self::BEHEERDER_GROUP) === false) { + $this->logger->warning( + '[OrganisationMembersController] Access refused (not a beheerder)', + ['uid' => $user->getUID(), 'organisationUuid' => $organisationUuid] + ); + return new JSONResponse( + data: ['error' => 'Only a beheerder of this organisation may grant or revoke access'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + try { + $organisationService = $this->getOrganisationService(); + $callerOrganisations = $organisationService->getUserOrganisations(); + } catch (\Exception $e) { + $this->logger->error( + '[OrganisationMembersController] Failed to resolve caller organisations', + ['uid' => $user->getUID(), 'error' => $e->getMessage()] + ); + return new JSONResponse( + data: ['error' => 'Unable to verify organisation membership'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + foreach ($callerOrganisations as $organisation) { + if ($organisation->getUuid() === $organisationUuid) { + return null; + } + } + + $this->logger->warning( + '[OrganisationMembersController] Access refused (beheerder of a different organisation)', + ['uid' => $user->getUID(), 'organisationUuid' => $organisationUuid] + ); + return new JSONResponse( + data: ['error' => 'Only a beheerder of this organisation may grant or revoke access'], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end authorizeBeheerder() + + /** + * Resolve OpenRegister's OrganisationService via the DI container. + * + * String-based lookup (not a compile-time type-hint) matches the + * existing pattern in `ContactpersonenController::getMe()` and + * `OrganizationHandler` — OpenRegister is a required app dependency but + * its classes are not part of this app's own composer autoload map. + * + * @return \OCA\OpenRegister\Service\OrganisationService The service instance. + * + * @throws \Throwable When OpenRegister is unavailable. + */ + private function getOrganisationService(): \OCA\OpenRegister\Service\OrganisationService + { + return $this->container->get('OCA\OpenRegister\Service\OrganisationService'); + }//end getOrganisationService() +}//end class diff --git a/openspec/changes/archive/2026-07-24-multi-org-membership/.openspec.yaml b/openspec/changes/archive/2026-07-24-multi-org-membership/.openspec.yaml new file mode 100644 index 00000000..f4dd94eb --- /dev/null +++ b/openspec/changes/archive/2026-07-24-multi-org-membership/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-24 diff --git a/openspec/changes/archive/2026-07-24-multi-org-membership/context-brief.md b/openspec/changes/archive/2026-07-24-multi-org-membership/context-brief.md new file mode 100644 index 00000000..2f871920 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-multi-org-membership/context-brief.md @@ -0,0 +1,37 @@ +# Context Brief: multi-org-membership + +## What +Let one user act for **multiple organisations** (samenwerkingsverbanden, shared service centres, herindeling transitions): an organisation switcher in the app UI, and a self-service flow to give a colleague access to your organisation. Closes softwarecatalog#371. + +## Why (evidence) +- VNG Softwarecatalogus issues **#57** and **#60** — one account active in multiple organisations. +- VNG **#65** — self-service colleague access (today an admin must do it). +- Dutch municipal reality: shared service centres and samenwerkingsverbanden mean one person legitimately works for several organisations; herindelingen create transition periods with dual membership. + +## The hard part is ALREADY BUILT — verify before you build anything +`openregister/lib/Service/OrganisationService.php` already ships (verified 2026-07-24): +- `getUserOrganisations(bool $_useCache = true): array` (line ~474) +- `getActiveOrganisation(?array $preloadedOrgs = null): ?Organisation` (line ~513) +- `setActiveOrganisation(string $organisationUuid): bool` (line ~560) + +So membership + active-org switching exist at the platform layer. **Read those methods first** and build on them — do NOT reimplement membership in softwarecatalog. Check what HTTP surface OpenRegister already exposes for them (grep its `appinfo/routes.php` and controllers); if an endpoint already exists, consume it rather than adding a proxy controller here (ADR-011: check OpenRegister core before adding your own; ADR-022: apps consume OR abstractions — a pass-through controller is a `hydra-gate-redundant-controller` failure). + +Softwarecatalog currently has **no org-switcher UI** and **no invite flow** (`grep -rli "invite\|uitnodig" src/ lib/` → nothing). `lib/Controller/ContactpersonenController.php` references the org primitives — read it to see the existing pattern. + +## Scope +IN: +- **Organisation switcher** in the app UI (header/nav area) listing the user's organisations and switching the active one; the whole app's tenant context (`X-OpenRegister-Organisation`) must follow the switch, and lists/pages must refresh to the newly-active organisation. +- **Self-service colleague access**: an organisation member with the right role can grant an existing Nextcloud user access to their organisation (and revoke it). Build on the existing role/group machinery in `openspec/specs/sc-handlers/spec.md` (`ContactPersonHandler`, `addUsersToOrganization`) and `softwarecatalog-contacts-to-nc`. +- i18n (EN keys + nl + en_US), unit tests, docs. + +OUT: inviting brand-new users by email (that is user provisioning — out of scope); cross-organisation data merging (that is #370/organisation-merge); changing the RBAC model itself. + +## Design constraints +- 🔒 **Interacts directly with the security model just hardened in sc#395 (`schema-rbac-hardening`).** Schema RBAC now scopes reads by `_organisation` matched against the caller's organisation context. Switching the active organisation MUST change what the user can see — and must NOT become a way to read an organisation the user is not a member of. Membership has to be verified server-side on every switch; never trust a client-supplied organisation id. Include NEGATIVE tests: switching to a non-member organisation is refused, and after switching the user sees only that organisation's data. +- **Any register change goes in a NEW `lib/Settings/register.d/multi-org-membership.json` fragment — never edit the monolith** (ADR-037; a monolith edit was a silent no-op on installed instances until sc#396, and fragments remain the correct pattern). +- 🔑 Register store object types by **schema SLUG** against `voorzieningenConfig.register` (the `useSelfFetchList.js` pattern) — several `voorzieningen_config._schema` keys are never populated; that mistake shipped a dead org picker (sc#392). +- ADR-012 `@conduction/nextcloud-vue` components; modals/dialogs each in their own file; `NcSelect` needs `inputLabel`. +- Security change ⇒ `hydra-gate-security-change-has-tests` requires tests. SPDX docblocks on new lib/ PHP. +- Spec deltas: `### Requirement: ` headers; MUST/SHALL on the requirement's FIRST physical line; no angle brackets in requirement bodies; `#### Scenario:` GIVEN/WHEN/THEN per MUST/SHALL. +- `@spec` anchors → canonical `openspec/specs//spec.md#requirement-`, NEVER a change dir (archive moves it). +- ⚠️ After `openspec archive`, run `grep -c '^### ' openspec/specs//spec.md` and `git diff -- openspec/specs/` — the archiver silently DELETES legacy `### REQ-NNN:` requirements (hydra#376). diff --git a/openspec/changes/archive/2026-07-24-multi-org-membership/design.md b/openspec/changes/archive/2026-07-24-multi-org-membership/design.md new file mode 100644 index 00000000..817ba849 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-multi-org-membership/design.md @@ -0,0 +1,276 @@ +# Design: multi-org-membership + +## Architecture Overview +SoftwareCatalog is a thin OpenRegister client (ADR-001/ADR-022): it stores no +domain tables of its own and already stores organisation *identity* in +OpenRegister's `Organisation` entity via `OCA\OpenRegister\Service\ +OrganisationService` (injected throughout `ContactpersonenController`, +`OrganizationHandler`, `SoftwareCatalogueService`, etc. — see +`../openregister/lib/Service/OrganisationService.php`). That service already +implements every membership primitive this change needs: + +- `getUserOrganisations()` — the caller's organisations (session-cached). +- `getActiveOrganisation()` — the caller's currently-active organisation + (session + persistent `IConfig` user value). +- `setActiveOrganisation($uuid)` — switches the active organisation, + **throwing unless `Organisation::hasUser($callerId) === true`** — i.e. + membership is already verified server-side, from the authoritative + `Organisation.users` array, never from a client-supplied claim. +- `joinOrganisation($uuid, $targetUserId = null)` / + `leaveOrganisation($uuid, $targetUserId = null)` — add/remove a user from + `Organisation.users`. + +`OrganisationController` (`../openregister/lib/Controller/ +OrganisationController.php`) already exposes all of the above over HTTP +(`appinfo/routes.php` lines 937-960): `GET /api/organisations` (list + stats), +`GET /api/organisations/active`, `GET /api/organisations/{uuid}`, +`POST /api/organisations/{uuid}/set-active`, `POST /api/organisations/{uuid}/join`, +`POST /api/organisations/{uuid}/leave`. Every one of those actions is +`@NoAdminRequired` + session-scoped or membership-gated; `join`/`leave` +additionally already refuse a caller who tries to enrol/remove *another* user +unless that caller is a Nextcloud admin or the organisation's `owner` field +(`canManageOrganisationMembers()`). + +**Consequence for this design**: the organisation switcher is a pure +frontend consumer of OpenRegister's existing HTTP surface — no new +SoftwareCatalog PHP is needed for switching. SoftwareCatalog's own +`OrganisationMembersController` is needed for exactly one reason: its +`beheerder`-role authorisation model (any org member who is also in the NC +group `beheerder` may manage that org's membership) is *not* the same +authorisation model OpenRegister's `join`/`leave` enforce (admin or single +`owner` only). SoftwareCatalog's controller adds that domain-specific +authorisation check and then delegates the mutation itself to +`OrganisationService::joinOrganisation()`/`leaveOrganisation()` — it does not +reimplement or duplicate the membership storage. + +``` +Frontend (Vue) SoftwareCatalog (PHP) OpenRegister (PHP) +───────────────── ──────────────────── ────────────────── +OrganisationSwitcher.vue ───GET──▶ ContactpersonenController (existing, unchanged) + (list + active) ::getMe() [existing endpoint] + ───POST─────────────────────────────────▶ OrganisationController + (switch) ::setActive() + → OrganisationService + ::setActiveOrganisation() + (membership verified + server-side) + +GrantOrganisationAccessModal.vue + (members list) ───GET──────────────────────────────────▶ OrganisationController::show() + (hasAccessToOrganisation() gate) + (grant / revoke) ───POST─▶ OrganisationMembersController (NEW — beheerder-role gate) + ::grant() / ::revoke() + → OrganisationService + ::joinOrganisation()/::leaveOrganisation() +``` + +## API Design + +### `POST /apps/openregister/api/organisations/{uuid}/set-active` (existing, consumed unchanged) +Frontend calls this directly. No SoftwareCatalog route added. + +### `GET /apps/softwarecatalog/api/me` (existing, consumed unchanged) +Already returns `organisations.active` and `organisations.all` (uuid, naam, +id, slug) — the switcher's list source. + +### `GET /apps/openregister/api/organisations/{uuid}` (existing, consumed unchanged) +Frontend calls this directly for the member list (`organisation.users`, +an array of NC user ids) when the grant/revoke modal opens. Already gated by +`hasAccessToOrganisation()`. + +### `POST /apps/softwarecatalog/api/organisations/{uuid}/members` (NEW) +**Request:** +```json +{ "userId": "j.devries" } +``` +**Response (200):** +```json +{ "message": "Successfully granted organisation access", "userId": "j.devries" } +``` +**Response (403 — caller not a beheerder of this organisation):** +```json +{ "error": "Only a beheerder of this organisation may grant access" } +``` +**Response (404 — target user does not exist):** +```json +{ "error": "User not found" } +``` + +### `DELETE /apps/softwarecatalog/api/organisations/{uuid}/members/{userId}` (NEW) +**Response (200):** +```json +{ "message": "Successfully revoked organisation access", "userId": "j.devries" } +``` +**Response (403):** same shape as grant. + +## Database Changes +None. `lib/Settings/register.d/multi-org-membership.json` is added as an +**empty-but-present** fragment file only if a follow-up needs a config +surface; this change stores nothing new — all state already lives in +OpenRegister's `Organisation` entity and Nextcloud's `IGroupManager`/ +`IUserManager`. (Decision: omit the fragment entirely rather than ship a +no-op file — see Decisions below.) + +## Nextcloud Integration +- Controllers: NEW `OCA\SoftwareCatalog\Controller\OrganisationMembersController` + (extends `OCP\AppFramework\Controller`, `#[NoAdminRequired]` + + `#[NoCSRFRequired]` on `grant`/`revoke`, following + `ContactpersonenController`'s constructor-injection pattern: + `IUserSession`, `IUserManager`, `IGroupManager`, `ContainerInterface` + (to reach OpenRegister's `OrganisationService`/`OrganisationMapper`), + `LoggerInterface`). +- Services: consumes `OCA\OpenRegister\Service\OrganisationService` and + `OCA\OpenRegister\Db\OrganisationMapper` via the container, exactly as + `ContactpersonenController::getMe()` and `OrganizationHandler` already do + — no new SoftwareCatalog service class for membership itself. +- Mappers/Entities: none new. +- Events/Hooks: none new. + +## Security Considerations +This is a security-relevant change (`hydra-gate-security-change-has-tests` +applies) because it changes *which* organisation's data a session is scoped +to, and *who* may become a member of an organisation. + +1. **Switch is never client-trusted.** The switcher POSTs only the target + `uuid` (a route parameter); OpenRegister's `setActiveOrganisation()` + re-derives membership from `Organisation::hasUser($this->userSession-> + getUser()->getUID())` — the session user, not anything the client sends — + and throws otherwise. SoftwareCatalog adds no bypass path. +2. **Grant/revoke authorisation is derived, not accepted.** The new + controller never trusts a client claim of "I am a beheerder of org X". It + derives the caller's own organisation membership from + `OrganisationService::getUserOrganisations()` (session-scoped, matching + what `setActiveOrganisation` itself trusts) intersected with NC group + `beheerder` (`IGroupManager::isInGroup($callerId, 'beheerder')`, the same + group `ContactPersonHandler::assignBeheerderRole()` already populates). + Both checks run server-side before the mutation; failing either returns + 403 without calling `joinOrganisation`/`leaveOrganisation` (deny before + any default grant — OR trap or#2025). +3. **Read-scoping is unaffected and does the rest of the work.** Once the + active organisation changes, `vendor-visibility-rbac`'s schema-level + `_organisation` matching (already hardened, unchanged by this proposal) + is what actually prevents cross-organisation reads — this change's job is + only to make sure the *session's* active organisation is trustworthy and + that the UI reflects it, which is why every list/detail view is forced to + re-fetch after a confirmed switch (see Decisions). +4. **Existing-user-only.** Granting access never creates a Nextcloud + account; the target must already resolve via `IUserManager::get()`, + closing the "enrol an unverified identity" vector entirely (email invites + are explicitly out of scope). + +## NL Design System +- `OrganisationSwitcher.vue` renders in `CnAppRoot`'s `#tenant-badge` slot + using only Nextcloud CSS variables (matches `CnTenantBadge`'s existing + styling contract — no `--nldesign-*` references, per nc-vue rules). +- `GrantOrganisationAccessModal.vue` is a `NcDialog` (has a heading + action + buttons — see nc-vue's NcDialog-vs-NcModal rule) living in its own file + under `src/modals/`, using `NcSelectUsers` (`inputLabel` set) for the user + picker and `NcListItem` + a destructive `NcActionButton` for revoke rows. + +## File Structure +``` +lib/ + Controller/ + OrganisationMembersController.php (NEW) +src/ + App.vue (modified — tenant-context wiring) + components/ + organisations/ + OrganisationSwitcher.vue (NEW) + modals/ + GrantOrganisationAccessModal.vue (NEW) + Modals.vue (modified — register new modal) + store/ + modules/ + organisatie.js (modified — grant/revoke actions) +tests/ + unit/ + Controller/ + OrganisationMembersControllerTest.php (NEW) + (src/**/*.spec.js for the Vue pieces, colocated per existing convention) +l10n/ + nl.js / nl.json (modified) + en_US.js / en_US.json (modified) +docs/ + features/ + multi-org-membership.md (NEW, with screenshots) +``` + +## Seed Data +Not applicable — this change introduces no new OpenRegister schema/register. +Organisations and users used in testing are the existing dev-environment +seed data (multiple `organisatie` objects with distinct `users` arrays are +already present from prior multi-tenancy work); manual test setup adds a +second Nextcloud user to one organisation's `beheerder` group to exercise +the grant/revoke flow. + +**`OrganisationSwitcher.vue` does not depend on nc-vue's `provide`/`inject` +tenant-context propagating correctly through a slot override.** `CnAppRoot` +mounts its own `provideTenantContext()` internally, and its default +`#tenant-badge` slot content (`CnTenantBadge`) is authored inside +`CnAppRoot.vue` itself, so it is unambiguously a true descendant for +`inject` purposes. Overriding that slot from `App.vue` (`