Skip to content

OpenConceptLab/ocl_online#648 | Organization roles: admin and member - #983

Open
paynejd wants to merge 3 commits into
masterfrom
ocl_online-648-org-roles
Open

paynejd wants to merge 3 commits into
masterfrom
ocl_online-648-org-roles

Conversation

@paynejd

@paynejd paynejd commented Nov 5, 2026

Copy link
Copy Markdown
Member

Today every member of an organization has the same rights over it. This gives each membership a role, so editing or deleting the org and managing its members need an admin. It's part of OpenConceptLab/ocl_online#648. The TBv3 side comes in a separate PR.

What changes

Model

  • Role (table org_roles), with rows admin and member in core/fixtures/org_roles.yaml.
  • OrganizationMember, an explicit through on UserProfile.organizations, on the existing table user_profiles_organizations, adding role.
  • An org's creator is an admin. A member added with no role is a plain member.

Needs an admin or staff

  • Org PUT, PATCH, DELETE
  • Logo POST, DELETE
  • Overview PUT, PATCH, DELETE
  • Client configs POST
  • Extras PUT, PATCH, DELETE
  • Adding a member, changing a role, removing someone else
  • Deleting the org through bulk import

Stays with any member

  • Everything on the org's repositories, private ones included: viewing, creating, editing, deleting, exports.
  • Pins and the URL registry.
  • Leaving the org.

is_admin_for keeps its meaning, since it's about a repository, not the org's role.

The last active admin can't be removed, leave or be demoted, and staff are held to this too (409). A user hard delete is refused the same way, naming the orgs. Deactivating a user is still possible, and staff can promote someone afterwards.

API

  • GET /orgs/:org/members/ and /users/: each row has role, in the list, verbose and summary shapes.
  • GET /orgs/:org/: adds user_role, the requester's role or null.
  • PUT /orgs/:org/members/:user/: optional body {"role": "admin"|"member"}. A new member defaults to member, and an existing one keeps their role if none is sent, so clients that send no body keep working. A role that doesn't exist is 400, demoting the last admin is 409. A repeat PUT no longer records a second Joined event.
  • DELETE /orgs/:org/members/:user/: removing the last admin is 409, beside the existing 409 for the only member.

One thing that isn't obvious

Editing a repository looks up its parent org by running OrganizationDetailView with the same PUT request, so that view's write permission applies to repository edits too. get_object_for_path now passes is_parent_lookup=True, and the org view keeps the member check in that case. Without it, members couldn't edit repositories.

Migration and rollout

orgs 0023 creates and seeds Role, and adds role_id with one ALTER TABLE at database default admin, so every existing member becomes an admin and existing orgs behave the same. users 0038 changes state only.

  • The database default stays admin. It only applies to a row inserted without a role, which means code older than the column, during a rollout or after a rollback. New code always names the role.
  • To roll back, roll back the image, not the migration. Old code runs fine on the new schema. Reversing and reapplying the migration would turn every plain member into an admin.

Rehearsed on a scratch database with old code on the new schema, forward, reverse and forward again.

Two things to confirm in review (OpenConceptLab/ocl_online#648): that every existing member becomes an admin, and that deactivating an org's last active admin stays allowed.

Tests

  • Full suite without Elasticsearch: 3,108 OK, 2 skipped. Elasticsearch-tagged tests in the files this changes: 33 OK. CI's pylint -j0 core/: 10.00/10.
  • Tests for each permission, as a plain member and as an admin.
  • Races on real connections: two members leaving at once, and an admin being demoted who tries to promote themselves back.

Reviews

Two Codex passes, a simplification review and an adversarial pass, in full on the ticket. Fixed from them: a hard delete could remove the last admin, older code could create an org with no admin during a rollout, and rights were read before taking the org's lock. About 280 lines of repeated tests were cut. No P0 or P1 is open.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AaZ4qUSJnKeDYRubKa6DYK

paynejd and others added 3 commits November 5, 2026 11:13
A membership now has a role, through a Role model and an explicit through
model on the existing membership table. Only an admin or staff can edit or
delete an organization or manage its members and their roles, and the last
admin can't be removed or demoted. Any member keeps what they have on the
organization's repositories, private ones included, and can still leave.

Every existing member becomes an admin, so existing organizations behave the
same after the migration. An organization's creator is an admin from now on.

The members list gains each member's role, the organization detail gains the
requester's role, and PUT on a member takes an optional role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qUSJnKeDYRubKa6DYK
… a hard delete keeps the last admin

A request that waited for the org's lock now uses the rights its sender has
once it gets the lock, so an admin being demoted can't promote themselves back.

A user hard delete is refused while they're the last active admin of an org.
Deactivating stays possible, as it's how staff block an account.

A membership row inserted without a role is an admin, which is what code older
than the column means by a member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qUSJnKeDYRubKa6DYK
… unused related names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qUSJnKeDYRubKa6DYK
@paynejd
paynejd requested a review from snyaggarwal November 5, 2026 16:43

@snyaggarwal snyaggarwal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other thoughts:

  • Do we want role to be in facet as well?
  • Should member roles be part of org summary?

Comment thread core/orgs/models.py
from core.users.models import Follow


class Role(models.Model):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model name and table name dont match intent.

Generic: (I would recommend)
Model name feels generic and shouldn't be here, it should be in core.common.models
and tablename needs to be roles -- db_table = 'roles'

Comment thread core/orgs/models.py
class Meta:
db_table = 'org_roles'

name = models.CharField(max_length=100, unique=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be restricted with choices

Comment thread core/orgs/serializers.py
return ClientConfigSerializer(obj.client_configs.filter(
is_active=True), many=True).data if self.include_client_configs else None

def get_user_role(self, obj):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user_role should be popped from response for anonymous requests

Comment thread core/orgs/models.py

def is_last_admin(self, user_profile):
"""True if no other active admin would remain without `user_profile`. Every org keeps at least one."""
return self.is_admin(user_profile) and not self.memberships.filter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is making to queries to memberships relation.

admins = self.memberships.filter(role_id=ROLE_ADMIN_ID, userprofile__is_active=True).values_list('userprofile_id', flat=True)
return len(admins) == 1 and admins[0] == user_profile.id

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants