Skip to content

Self-hosted Enterprise (licence): cannot invite a 2nd user - seat limit enforced against max_seats DB column instead of licence seat count #7862

Description

@Holmus

Summary

On self-hosted Enterprise (licence-activated) instances, inviting any user beyond the first organisation admin fails, even though the licence grants more seats and the UI correctly shows them as available (e.g. "1 of 20 seats used").

Two different, misleading errors surface depending on the invite method, but both have the same root cause.

Affected versions

Present from v2.217.1 (first release containing #6663) through latest (v2.245.0). Confirmed by reading source at v2.233.0 and main, and reproduced on a self-hosted Enterprise instance running v2.233.0. Setting max_seats manually (see workaround) resolves it, confirming the mechanism.

Symptoms

A. "Invite members" (email invite): invitee registers, then accepting fails with:

The organisation you have been invited to has no seats available. Please contact the organisation administrator to resolve this before trying again.

B. "Copy Invite Link" (shareable link): invitee registers, then sees:

We could not validate your invite, please check the invite URL and email address you have entered is correct.

In both cases the org shows e.g. "1 of 20 seats" used and the licence grants 20.

Root cause

Organisation.over_plan_seats_limit() only consults subscription metadata (which correctly reads the licence) when has_paid_subscription() is true — i.e. when a subscription_id is set. A licence-activated Enterprise org has no subscription_id (that's a Chargebee/SaaS field), so it falls through to the raw Subscription.max_seats DB column, which is never populated from the licence and defaults to MAX_SEATS_IN_FREE_PLAN = 1.

# api/organisations/models.py
def over_plan_seats_limit(self, additional_seats=0):
    if self.has_paid_subscription():   # bool(subscription_id) -> False for licence-only enterprise
        return self.num_seats + additional_seats > self.subscription.get_subscription_metadata().seats
    # licence-only enterprise lands here:
    return self.num_seats + additional_seats > getattr(self.subscription, "max_seats", MAX_SEATS_IN_FREE_PLAN)
    #                                                   ^ defaults to 1, never set from the licence

This contradicts the display and invite-creation paths, which both go through get_subscription_metadata()_get_subscription_metadata_for_self_hosted()licence.get_licence_information().num_seats (= 20). So:

  • Admin can create the invite (creation check in InviteViewSet.perform_create uses metadata = 20) ✅
  • UI shows "1 of 20" (serializer reads metadata = 20) ✅
  • Invitee cannot accept (join_organisation_from_inviteover_plan_seats_limit uses max_seats = 1) ❌

1 existing + 1 joining = 2 > 1SubscriptionDoesNotSupportSeatUpgrade is raised on every join past the first user.

The two broken branches are marked # pragma: no cover, and the e2e Enterprise fixture (api/e2etests/e2e_seed_data.py) seeds subscription_id="test_subscription_id", which routes through the paid branch and masks the bug in tests.

Why the invite-link error is different (and misleading)

acceptInvite() in frontend/common/stores/account-store.js posts to the invite-link endpoint first, and on any error falls back to the email-invite endpoint unless the verify_seats_limit_for_invite_links flag is enabled:

data.post(`${Project.api}users/join/link/${id}/`)
  .catch((error) => {
    if (Utils.getFlagsmithHasFeature('verify_seats_limit_for_invite_links') && error.status === 400) {
      API.ajaxHandler(store, error); throw error          // surface the real seat error
    }
    return data.post(`${Project.api}users/join/${id}/`)   // else retry against EMAIL endpoint
  })

When the flag is off (default on self-hosted), the genuine 400 seat error from users/join/link/<hash>/ is swallowed and the link hash is retried against the email-invite endpoint users/join/<hash>/. That hash doesn't exist in the Invite table → 404 ("No Invite matches the given query") → the misleading "could not validate your invite" message. The real problem is still the seat limit.

Steps to reproduce

  1. Self-hosted Enterprise build, org activated via licence file with num_seats > 1 (e.g. 20), no Chargebee subscription_id.
  2. Confirm organisations_subscription.max_seats is still its default of 1.
  3. Org has 1 member (the admin). UI shows "1 of 20 seats".
  4. Invite a second user via "Invite members" → invitee registers → accept fails with "no seats available".
  5. Invite via "Copy Invite Link" → invitee registers → fails with "could not validate your invite".

Expected

A licence-activated Enterprise org should allow members to join up to the licence's num_seats. Seat enforcement should agree with what the UI and invite-creation flow display.

Actual

Enforcement uses Subscription.max_seats (= 1), blocking all joins past the first user, regardless of the licence seat count.

Suggested fix (for maintainers to choose)

  • Make over_plan_seats_limit() consult get_subscription_metadata().seats for self-hosted Enterprise regardless of subscription_id, or
  • Populate Subscription.max_seats from licence.num_seats when a licence is imported/applied.

Either also removes the need for the silent email-endpoint fallback to mask seat errors on the invite-link path.

Workaround (confirmed)

Set organisations_subscription.max_seats to >= the licence seat count (via Django admin or directly in Postgres). This resolves both symptoms immediately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions