Summary
Add a first-class Group concept to nebi so workspaces, registries, and the admin role can be granted to a group of users instead of one user at a time.
Relevant issue #72
Works in both deployment modes:
- Native (no OIDC): admins create groups and manage members in the nebi UI.
- OIDC: groups + members come from the IdP
groups claim. Auto-created on first login, members synced JIT from each ID token. Read-only in nebi.
Scope
Groups can be granted permission on:
- Workspaces (viewer / editor) — same roles as user sharing today.
- Registries (read / write).
- The admin role (every member becomes an effective admin).
Effective permission for a user = union of direct grants + grants via any group they belong to (standard Casbin g semantics — no matcher changes needed).
Data model
New tables (GORM AutoMigrate):
groups — id (uuid), name (unique), description, source (native | oidc), timestamps, soft delete.
group_members — composite PK (group_id, user_id), created_at.
group_permissions — group_id, workspace_id, role_id (reuses existing roles table). Sibling of the existing per-user permissions table; not a discriminator on permissions (keeps existing queries untouched).
Registry-by-group and admin-by-group are expressed purely as Casbin policies (no extra table).
RBAC (Casbin)
internal/rbac/model.conf already has g = _, _ — wire it up, no matcher rewrite.
- Membership:
g(user_id, group_id)
- Group on workspace:
p(group_id, "ws:"+workspace_id, "read"|"write")
- Group on registry:
p(group_id, "reg:"+registry_id, "read"|"write")
- Group as admin:
p(group_id, "admin", "*")
Existing IsAdmin / CanReadWorkspace / CanWriteWorkspace resolve transitively for free.
DB write + Casbin write must happen in a single transaction (matches today's per-user pattern). On group delete, hard-remove all Casbin grouping + policies for that group id (Casbin doesn't honor GORM soft-delete).
API
# Admin group CRUD (native groups only — OIDC groups return 409 on edit)
GET /api/v1/admin/groups
POST /api/v1/admin/groups
GET /api/v1/admin/groups/{id}
PATCH /api/v1/admin/groups/{id}
DELETE /api/v1/admin/groups/{id}
POST /api/v1/admin/groups/{id}/members
DELETE /api/v1/admin/groups/{id}/members/{user_id}
# Group as admin / registry grants
POST /api/v1/admin/groups/{id}/grant-admin
DELETE /api/v1/admin/groups/{id}/grant-admin
POST /api/v1/admin/registries/{id}/grant-group
DELETE /api/v1/admin/registries/{id}/grant-group/{group_id}
# Picker for non-admins
GET /api/v1/groups/me # only groups the caller belongs to
# Workspace sharing (parallel to existing user share endpoints)
POST /api/v1/workspaces/{id}/share-group {group_id, role}
DELETE /api/v1/workspaces/{id}/share-group/{group_id}
GET /workspaces/{id}/collaborators extended to return users[] and groups[] side by side, each with a source badge.
Authorization for share-group: caller must be admin OR (workspace owner AND member of group_id). This enforces the rule that owners can only share with groups they belong to.
OIDC sync
- Add
groups to the requested OIDC scopes in internal/auth/oidc.go.
- On every login, read the
groups claim from the ID token.
- For each name in the claim: upsert a
groups row with source=oidc; ensure group_members(user_id, group_id) exists; mirror to Casbin (g(user, group)).
- For groups the user is currently a member of (
source=oidc) but not in this login's claim: remove membership + Casbin grouping rule.
- OIDC groups with zero members are kept (workspace shares stay valid through churn).
Stale-until-next-login is acceptable; no background poll, no per-IdP admin API.
Frontend
- New admin page:
Groups — list, create (native), edit, delete, manage members. OIDC groups shown read-only with a badge.
ShareDialog (frontend/src/components/sharing/ShareDialog.tsx): add a tabbed/segmented selector for User vs Group; group dropdown sourced from GET /groups/me.
- Collaborators list renders groups inline with users, distinguished by an icon +
native/oidc badge.
- Admin user-management page: show which groups a user belongs to.
Out of scope (first cut)
- Group-of-groups / nesting.
- Per-member roles within a group.
- Background reconcile worker for DB ↔ Casbin drift.
- IdP admin-API polling.
- Per-user direct-override semantics ("downgrade Alice even though group says editor").
Files most affected
internal/models/ — new group.go, group_member.go, group_permission.go.
internal/db/db.go — add models to AutoMigrate.
internal/rbac/rbac.go — new helpers for group grouping + group-scoped grants.
internal/service/workspace_permissions.go — extend collaborator listing; new ShareWorkspaceWithGroup / UnshareWorkspaceFromGroup.
internal/service/admin.go (or new groups.go) — group CRUD + member ops.
internal/auth/oidc.go — claim extraction + JIT sync.
internal/api/handlers/ — new group.go, additions to workspace.go, registry.go, admin.go.
internal/api/router.go — wire new routes.
frontend/src/pages/admin/Groups.tsx (new), frontend/src/components/sharing/ShareDialog.tsx, frontend/src/types/models.ts.
Summary
Add a first-class Group concept to nebi so workspaces, registries, and the admin role can be granted to a group of users instead of one user at a time.
Relevant issue #72
Works in both deployment modes:
groupsclaim. Auto-created on first login, members synced JIT from each ID token. Read-only in nebi.Scope
Groups can be granted permission on:
Effective permission for a user = union of direct grants + grants via any group they belong to (standard Casbin
gsemantics — no matcher changes needed).Data model
New tables (GORM AutoMigrate):
groups—id (uuid),name (unique),description,source(native|oidc), timestamps, soft delete.group_members— composite PK(group_id, user_id),created_at.group_permissions—group_id,workspace_id,role_id(reuses existingrolestable). Sibling of the existing per-userpermissionstable; not a discriminator onpermissions(keeps existing queries untouched).Registry-by-group and admin-by-group are expressed purely as Casbin policies (no extra table).
RBAC (Casbin)
internal/rbac/model.confalready hasg = _, _— wire it up, no matcher rewrite.g(user_id, group_id)p(group_id, "ws:"+workspace_id, "read"|"write")p(group_id, "reg:"+registry_id, "read"|"write")p(group_id, "admin", "*")Existing
IsAdmin/CanReadWorkspace/CanWriteWorkspaceresolve transitively for free.DB write + Casbin write must happen in a single transaction (matches today's per-user pattern). On group delete, hard-remove all Casbin grouping + policies for that group id (Casbin doesn't honor GORM soft-delete).
API
GET /workspaces/{id}/collaboratorsextended to returnusers[]andgroups[]side by side, each with asourcebadge.Authorization for
share-group: caller must be admin OR (workspace owner AND member ofgroup_id). This enforces the rule that owners can only share with groups they belong to.OIDC sync
groupsto the requested OIDC scopes ininternal/auth/oidc.go.groupsclaim from the ID token.groupsrow withsource=oidc; ensuregroup_members(user_id, group_id)exists; mirror to Casbin (g(user, group)).source=oidc) but not in this login's claim: remove membership + Casbin grouping rule.Stale-until-next-login is acceptable; no background poll, no per-IdP admin API.
Frontend
Groups— list, create (native), edit, delete, manage members. OIDC groups shown read-only with a badge.ShareDialog(frontend/src/components/sharing/ShareDialog.tsx): add a tabbed/segmented selector for User vs Group; group dropdown sourced fromGET /groups/me.native/oidcbadge.Out of scope (first cut)
Files most affected
internal/models/— newgroup.go,group_member.go,group_permission.go.internal/db/db.go— add models toAutoMigrate.internal/rbac/rbac.go— new helpers for group grouping + group-scoped grants.internal/service/workspace_permissions.go— extend collaborator listing; newShareWorkspaceWithGroup/UnshareWorkspaceFromGroup.internal/service/admin.go(or newgroups.go) — group CRUD + member ops.internal/auth/oidc.go— claim extraction + JIT sync.internal/api/handlers/— newgroup.go, additions toworkspace.go,registry.go,admin.go.internal/api/router.go— wire new routes.frontend/src/pages/admin/Groups.tsx(new),frontend/src/components/sharing/ShareDialog.tsx,frontend/src/types/models.ts.