Let users create and manage their own Nextcloud groups, with optional storage grants allocated to members.
Author: Frederik Orellana, Technical University of Denmark (fror@dtu.dk) — developed for the ScienceData cloud platform.
License: AGPL-3.0
user_group_admin extends Nextcloud's group system so ordinary users can:
- Create groups and invite members (invite/accept workflow)
- Allow open-join groups (no invite required)
- Keep private groups (hidden from non-members)
- Allocate a storage grant from their own quota to the group — usage consumed by the grant is billed to the group owner, not the members
- Cross-silo sync: group membership changes are propagated to all registered silos via the
files_shardinginternal API
In a ScienceData sharded deployment the master holds the authoritative group registry; silo nodes mirror it so that group membership is available locally for DAV and sharing operations.
- Nextcloud 34+
- PHP 8.2+
- Node.js 18+ and webpack (for frontend build)
files_sharding(optional) — required for cross-silo group sync
occ app:enable user_group_adminMigrations run automatically and create two tables:
user_group_admin_groups— group metadata (name, owner, type, storage grant)user_group_admin_members— member roster with invitation state
Group owners invite users by username. Invited users see a pending invitation in their interface and can accept or decline. Accepted memberships are immediately synced to all silos.
When a group is set to open-join, any user can join without an invitation. The owner can still remove members.
Private groups are not visible to non-members in search or group listings.
A group owner can allocate a fixed storage amount (e.g. 10 GB) to the group. Members of the group can use storage up to that grant without it counting against their own free quota. The consumed storage is billed to the group owner by files_accounting.
The grant is configured in the group's Settings tab:
Storage grant — Allocate storage from your own quota to group members.
Amount: [dropdown: None / 1 GB / 5 GB / 10 GB / 50 GB / 100 GB / 500 GB / 1 TB]
Two limits, both enforced by GrantQuotaWrapper as a hard write-stop (507 past the ceiling; existing data untouched):
storage_grant— the per-member allocation: each member's grant subfolder (.uga_grants/{gid}/) is capped at this, independent of their personal quota.storage_grant_total— the committed pool: the owner's total commitment across the whole group. A member's grant free space ismin(per-member remaining, pool remaining), where pool usage aggregates every accepted member's recordedstorage_used(refreshed daily by theGrantFolderUsagejob) plus the current member's live usage. Unset (0) → no pool cap, per-member behaviour only (no regression). Because the aggregate is day-granular and a silo may not hold every member's row, the pool cap is a conservative backstop against over-commitment, not a to-the-byte guarantee — it never falsely blocks.
Separately from the grant folder, a group owner can allocate extra free quota on their members' own home directories, drawn from their own assigned quota — the "OneDrive alternative" option. It's set from the same group Settings tab ("Home-directory top-up", e.g. 100 GB, empty to remove) and is stored/enforced by files_accounting (files_accounting_topup), which raises each member's effective free quota (and native hard-stop). The control calls the files_accounting grouptopup OCS endpoint, which authorises the group owner (not just admins); on a silo the write is forwarded to the master. Hidden gracefully if files_accounting is not installed.
Group ownership — and with it the billing responsibility for the group's grant folder and home top-ups — can be handed to another member, so a group is never stranded when its owner (e.g. a departing PI) leaves.
- Consent by default. The owner offers ownership to an active member from the group's Settings tab ("Transfer ownership"). The recipient gets a bell notification and an Accept / Decline banner on the group, showing the committed amount they'd take on. Ownership changes only when they accept — it can't be assigned silently, because the owner's own quota backs the group's sponsored storage.
- Soft warning, no block. If the committed pool is close to or over the proposed owner's quota, the offer surfaces a warning but never blocks; if members later exhaust it, the owner simply arranges a larger quota.
- Domain owner & admin. Besides the current owner, an administrator or the institution's domain owner (owner of the members'
schacHomeOrganizationgroup, e.g.dtu.dk) may initiate a transfer — and may force it without consent, so IT can reassign a departed PI's group without full admin rights. - No-orphan. If an owner's account is deleted, their groups are reassigned automatically to the domain owner, or to the
HIDDEN_OWNERsentinel (ownerless → unbilled, never deleted).
Because grant folders are stored per-member, a transfer moves no data — it is a metadata + billing-pointer change, propagated to all silos; files_accounting re-points billing automatically since it reads the group's owner.
When a group is created, updated, or deleted on the master, files_sharding propagates the change to all registered silos via POST /internal/users/{userId}/update and related endpoints. Silos store a local mirror so group membership is available without a round-trip to the master.
user_group_admin registers a custom IGroupBackend with Nextcloud. Groups whose names match the app's prefix are resolved through this backend rather than the default database backend. The backend reads from user_group_admin_groups and user_group_admin_members.
user_group_admin_groups
| Column | Type | Description |
|---|---|---|
id |
int (PK) | Auto-increment |
gid |
varchar | Group identifier |
owner |
varchar | User ID of group owner |
type |
varchar | invite / open / private |
storage_grant |
bigint | Per-member grant size in bytes (0 = none) |
storage_grant_total |
varchar | Committed-pool cap for the whole group (hard write-stop; empty = no pool cap) |
pending_owner |
varchar | Proposed new owner awaiting consent during an ownership transfer (empty = none) |
user_group_admin_members
| Column | Type | Description |
|---|---|---|
id |
int (PK) | Auto-increment |
gid |
varchar | Group identifier |
uid |
varchar | Member user ID |
state |
varchar | invited / member |
When files_sharding is present, write operations (create group, add member, remove member, delete group) are wrapped to also POST to each registered silo's internal API so the mirror stays current.
All endpoints under /ocs/v2.php/apps/user_group_admin/api/v1/. Authentication via Nextcloud session or admin token.
| Method | URL | Description |
|---|---|---|
GET |
/groups |
List groups (owner or member) |
POST |
/groups |
Create group |
DELETE |
/groups/{gid} |
Delete group (owner only) |
GET |
/groups/{gid}/members |
List members |
POST |
/groups/{gid}/members |
Invite or add member |
DELETE |
/groups/{gid}/members/{uid} |
Remove member |
POST |
/groups/{gid}/accept |
Accept invitation |
PUT |
/groups/{gid} |
Update group settings (type, storage grant) |
PUT |
/groups/{gid}/owner |
Offer ownership (uid); admin/domain owner may force=1 (no consent) |
PUT |
/groups/{gid}/owner/pending |
Accept a pending ownership offer |
DELETE |
/groups/{gid}/owner/pending |
Decline a pending ownership offer |
The frontend is built by the app's own webpack (apps/user_group_admin/webpack.config.js, splitChunks: false so each entry is a self-contained bundle). This keeps the app installable standalone — no core NC UI rebuild:
cd apps/user_group_admin
npm ci # first time only
npm run build # → apps/user_group_admin/js/{main,files-navigation,files-navigation-init}.jsNextcloud loads the app's JS from apps/user_group_admin/js/ (via Util::addScript). Do NOT copy the bundle into the core /dist/. A /dist/user_group_admin-*.js file shadows the shipped js/ and, because a core /dist/ entry only exists after a full NC UI build, it breaks app-store installability — if one exists, rm it. Commit the built js/ so git/app-store installs ship it (PHP-only changes need no build).
# Master
rsync -av --delete apps/user_group_admin/ master:/var/www/nextcloud/apps/user_group_admin/
# Silo1
rsync -av --delete apps/user_group_admin/ silo1:/var/www/nextcloud/apps/user_group_admin/
# Silo2
rsync -av --delete apps/user_group_admin/ silo2:/var/www/nextcloud/apps/user_group_admin/
# Enable on each node (runs migrations automatically)
occ app:enable user_group_adminDeploy the whole app dir (including the committed js/); there is no /dist/ step. After deploying, reload the web/PHP layer to clear OPcache — service php8.3-fpm reload on the pods, service apache24 restart on the FreeBSD boxes (mod_php). A JS change also needs a browser hard-refresh (the ?v= asset hash is global, not per-app).
On login, EnsureDomainGroupListener ensures a hidden group named after the user's home organisation (the domain part of the user@homeorg UID — our SAML uid_mapping is eppn, matching the old schacHomeOrganization) exists and the user is a member. This gives files_accounting's per-domain billing rollup something to group on, and ports the old ScienceData user_saml behaviour without patching user_saml. The group carries the HIDDEN_OWNER sentinel until an admin assigns the institution's real owner at onboarding; ownerless hidden groups are never billed. Bare (non-@) UIDs — local accounts — are skipped.