NEBULA.CAT // MFD-2600 BARCELONA · ES
BLOG/THE GITLAB GROUP THAT WENT READ-ONLY FOR A REASON NOBODY COULD SEE

// NOTES FROM THE TERMINAL

POST

The GitLab Group That Went Read-Only for a Reason Nobody Could See

2026-09-20 ~4 min read gitlabsaasaccess-control

Why are you here? Because GitLab.com showed you a number that made no sense, and now you are three tabs deep into billing documentation that was clearly written for a different product. Out of the jokes, this is the story of a group that went read-only for a reason nobody could see on the page that was supposed to show it.


We run a self-hosted GitLab CE instance for internal work, but we also keep a group on GitLab.com — call it ourcompany — that exists for exactly one reason: handing packages to external clients who should never see our internal instance. One day it went read-only. No warning, no email that anyone could find, just a banner.

The Problem

The banner said the namespace was over its seat quota. Fine, normal SaaS thing, except:

  • The group members page showed 5 members.
  • The usage/seat quota page, same group, same moment, showed 401.

Five and four-hundred-and-one are not the same number by any definition of "same number." Somewhere between those two pages, GitLab was counting something I could not see.

Read-only meant: no new repos, no new tokens, no admin changes. Which was a problem, because this namespace is how we ship packages to external clients, and "we can no longer configure client access" is the kind of sentence that gets escalated fast.

The Investigation

Theory 1: it's just the members page lying

Checked every subgroup, every project, counted invited members, counted pending invitations, counted service accounts. Nowhere close to 401. Whatever GitLab was counting for the quota, it wasn't "people listed as members" in any view the UI exposes.

Theory 2: maybe it doesn't matter why, just work around it

At some point "why does the counter say 401" became less urgent than "how do external clients pull packages while the group is locked." So the investigation pivoted from diagnose the billing bug to find an access path the read-only state doesn't block.

Attempt: group deploy token, nested subgroup

Created a private subgroup nested inside the main ourcompany group, added myself as Owner, to test whether group-level deploy tokens would work as a client-access mechanism.

403 Forbidden

Group deploy token creation, blocked. My read: the read-only state on the parent namespace propagates down to children, even to a brand-new subgroup that individually had nothing to do with the quota problem. A locked root locks everything under it.

Attempt: same idea, different namespace

We already had a separate group, ourcompany-public, used specifically for external package distribution and apparently not caught in the same read-only state. Rebuilt the test there instead: new private subgroup, two private repos, group-level deploy token.

It worked. Token created, no 403, no quota banner.

The Fix (Such As It Is)

Not a fix for the actual quota bug — that's still a mystery, filed as "billing weirdness, revisit if it happens again." What we actually shipped was a working access pattern that doesn't depend on the locked namespace at all:

  1. Client-facing repos live under ourcompany-public, a namespace that wasn't affected.
  2. Access is granted via group-level deploy tokens, one scope, tested against my own account as a fake client first before handing anything real out.
  3. Verified exactly what a deploy token can and can't do, because I did not want to find out the hard way:
CapabilityDeploy token
Clone / pull repo✅
Pull & push to package/container registry✅
git push❌
Open merge requests❌
Web UI / API access❌

So deploy tokens are genuinely read-only from the client's side — good for "here's your package feed," useless for any client who needs to actually collaborate on code. A client who needs to push or open MRs needs a real Developer-role member account, which is a billable seat again, which is the whole thing we were trying to route around.

The part that's still a work-in-progress

We use ClickUp (and Odoo, depending on the team) for tickets, and the obvious next step is tying deploy-token issuance and revocation to ticket status — token gets created when a client-access ticket opens, gets revoked automatically when it closes. Not built yet. Filed for later.

What I Would Tell Past Me

  • A read-only namespace state can block operations in subgroups that individually have nothing wrong with them. Don't waste time debugging the child; check the parent's quota status first.
  • The members-count UI and the seat-quota UI are not counting the same thing. Don't trust one to explain the other.
  • Deploy tokens are a real access-control primitive for external clients, not a workaround — as long as "read-only access to packages" is actually all the client needs. Confirm that before promising anything.
  • Test the access pattern against your own account before granting it to a real client. The 403 in the nested subgroup would have been a much worse discovery if a client had hit it first.

The 401-vs-5 mismatch is still unexplained. If GitLab.com ever tells me what it was actually counting, that's a two-line update to this post. I am not holding my breath.

// daniel.nebula.cat/blog/gitlab-seat-quota-lockout
— END OF DISPLAY · MFD-2600 —