Data and access

The role ladder

One ordering of roles, one decision function, and why an unreadable repository answers 404.

Every repository permission check asks one question: is the actor's role at least the operation's? (ADR 0021)

read < triage < write < maintain < admin < owner
OperationNeeded for
readclone a private repository, open issues, comment, review
triageclose, label and assign other people's issues and pull requests
writepush, merge, edit anyone's issues and comments, manage labels
maintainrepository settings: name, description, default branch
adminvisibility, collaborators, deleting the repository

The ladder is packages/permissions/src/index.ts › roleHierarchy, with atLeast(role, needed). The web app imports the same package to decide which buttons to show; the API decides for real.

Where a role comes from

apps/api/src/lib/repositories/access/repository-access.ts › roleOf:

  • A user's repository: its ownerId is owner. Anyone else has the role of their accepted collaborator row, or none.
  • An organization's repository: organization owners are owner, organization admins are admin, members get the organization's base permission, raised by their teams' roles and their own collaborator row. The highest wins (next lesson).

A collaborator row with no acceptedAt is an invitation and grants nothing. Nobody appears on a private repository, or gets one in their account, without consenting. Invitations lapse after 7 days, computed from createdAt, so nothing sweeps them.

The decision

apps/api/src/lib/repositories/access/repository-access.ts › decideAccess:

if (repository && canAccess(repository, role, operation)) return { ...repository, viewerRole: role };
if (!actor) throw new AuthenticationRequiredError();                         // 401: sign in
if (canAccess(repository, role, 'read')) throw new RepositoryForbiddenError(); // 403
throw new RepositoryNotFoundError();                                          // 404

Public repositories are readable by everyone, including actor: null.

An unreadable repository must look absent. A 403 would confirm that a private repository by that name exists. A readable one is safe to admit to, so too-low roles get 403.

Asking efficiently

RepositoryAccessService.authorize resolves the repository and the viewer's role in one query and returns viewerRole, so a caller needing a second decision (changing visibility needs admin inside a maintain request) does not query again. Queries listing many repositories join acceptedCollaboration(repositoryId, actor) and filter with canAccess.

authorizeById does the same by row id, for the other side of a pull request or a node id. canRead answers "may this user hear about it", used by notifications rather than requests.

Try it

apps/api/src/lib/repositories/access/repository-access.spec.ts tests decideAccess and roleOf with plain objects, no database. Add a case: an organization member with base permission read, a team granting maintain and a collaborator row granting write gets maintain.

Check yourself

Bob has a pending collaborator invitation with role write on Alice's private repository. Can he clone it?
Which status does a reader with role read get when trying to merge?

On this page