Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
104 changes: 104 additions & 0 deletions docs/features/multi-org-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!--
- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
- SPDX-License-Identifier: EUPL-1.2
-->

# 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.
10 changes: 9 additions & 1 deletion l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 10 additions & 1 deletion l10n/en_US.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);"
);
11 changes: 10 additions & 1 deletion l10n/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 10 additions & 1 deletion l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);"
);
11 changes: 10 additions & 1 deletion l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 9 additions & 0 deletions lib/Controller/ContactpersonenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()

Expand Down
Loading