ADR-0024: Enrollment Is an Operator Mode (allowlist, invite, open), and GitHub Sign-in Fails Closed
Context and Problem Statement
ADR-0019 added "Log in with GitHub" as a second human-auth provider, and the
foundation shipped in #259. Once CAIRN_GITHUB_CLIENT_ID and
CAIRN_GITHUB_CLIENT_SECRET are set, any GitHub account with a verified
primary email can sign in. The code confirms it:
authprovider.GitHubProvider.FinishLoginchecks the state, exchanges the code, fetchesGET /userandGET /user/emails, and returns an identity for the first primary verified email. There is no membership or identity check.httpapi.handleGitHubCallbackpasses that identity straight tos.sessions.Create.- The requested scopes are
read:user user:email. Cairn cannot see organisation membership even if it wanted to.
SPEC-0013's design left this open ("Should GitHub logins be allow-listed…? Default plan: any verified-email GitHub account may log in"). That default is now wrong:
- Cairn is multi-tenant. There are two profiles: the operator, who runs the instance, and users, whom the operator lets in (Cairn ADR-0029, Teams and tenancy). An instance open to every GitHub account is one where strangers get an account, create artifacts, consume storage and receive MCP grants. The operator chose none of that.
- A self-hosting customer wants GitHub login for their own organisation, without opening the instance to the world.
- The GitHub login docs are blocked on this. The variables appear in no
guide, no
.env.exampleand no compose file. Documenting them as they stand would publish "set these two variables and anyone on GitHub can sign in". - Switchboard has the same gap and is closing it with enrollment modes
(
SWITCHBOARD_ENROLLMENT_MODE=allowlist|invite|open, Switchboard SPEC-0033). An operator running both products should meet one model, not two.
The question: how does an operator restrict GitHub sign-in to the people they intend, and what happens when they enable the provider without saying who?
Decision Drivers
- Fail closed. Enabling a login provider must never, by omission, admit everyone. The unsafe configuration must require an explicit, greppable setting.
- Match how operators think. "Members of my GitHub org" is the common case, and "these specific people" is the other.
- One model across Cairn and Switchboard. The same variables, mode names,
default and semantics, and the same rule about whom the gate applies to. Joe
decided this in design review (2026-09-22): the mode is operator config,
CAIRN_ENROLLMENT_MODE, defaulting toinvitewhen GitHub login is configured, aligned with Switchboard'sSWITCHBOARD_ENROLLMENT_MODE. - No transition machinery. Cairn is pre-1.0. Nothing here keeps an old behaviour alive behind a deprecation window (design review, 2026-09-22).
- Least privilege at GitHub. Do not request
read:orgunless an organisation check is configured. - Identity that does not drift. GitHub logins can be renamed and later reclaimed by someone else. The numeric account id cannot.
- No steady-state GitHub traffic. ADR-0019's token containment stands: the access token lives only inside the callback.
- OIDC-only instances are unaffected by default. Where GitHub login is not
configured the mode defaults to
open, because the IdP already decides who exists. The dev login is untouched.
Considered Options
- A. Enrollment modes (
allowlist,invite,open;inviteby default when GitHub login is configured, otherwiseopen) with one operator allowlist,CAIRN_ENROLLMENT_ALLOW, in Switchboard's syntax. The gate is checked in the callback before a session exists, applies to enrollment (an identity not yet linked to an active Cairn user), and denies on any check error. - B. The same allowlist, re-checked on every GitHub sign-in, so leaving the organisation locks the person out at their next login.
- C. An email-domain allowlist, checking the verified primary email against operator domains.
- D. Keep it open, and rely on the operator to leave GitHub disabled unless they want open signup.
Decision Outcome
Chosen option: A.
Configuration
| Variable | Meaning |
|---|---|
CAIRN_ENROLLMENT_MODE | allowlist, invite or open. Default: invite when GitHub login is configured, otherwise open. The same names, default and meanings as Switchboard's SWITCHBOARD_ENROLLMENT_MODE. |
CAIRN_ENROLLMENT_ALLOW | comma-separated entries, in the same syntax as Switchboard's SWITCHBOARD_ENROLLMENT_ALLOW: a provider-qualified subject <issuer>|<subject> (a GitHub user is https://github.com|<numeric account id>); a verified email address; @<domain>, matching a verified email in that domain; or github-org:<login>, matching an active member of that GitHub organisation. |
The modes:
allowlist: an identity may enroll only if it matches an entry inCAIRN_ENROLLMENT_ALLOW.invite: an identity may enroll only if its verified email holds a pending team invitation, or it is an operator. Invitations and operators belong to Teams (ADR-0029). Before they exist nothing matches, soinviteadmits nobody and GitHub enrollment is closed (see "Fail closed"). Selecting it never fails startup.open: any identity the configured providers authenticate may enroll. This is the explicit open-signup setting.opencombined with a non-emptyCAIRN_ENROLLMENT_ALLOWis a startup error, because the operator's intent is ambiguous. With GitHub configured,openadmits every GitHub account, so cairnd logs a WARN at startup and the self-hosting guide calls it out.
Entries match case-insensitively. A GitHub user is named by numeric account id,
which GET /user already returns, never by login, because a renamed login can
be reclaimed by a stranger. An @<domain> entry proves control of an address in
that domain, not membership of anything (option C's weakness); it exists for
parity with Switchboard, and the operator guide says so next to it.
CAIRN_ENROLLMENT_MODE and CAIRN_ENROLLMENT_ALLOW replace the
CAIRN_ENROLLMENT, CAIRN_GITHUB_ALLOWED_ORGS and CAIRN_GITHUB_ALLOWED_USERS
of this ADR's first draft outright. Those never shipped, and nothing reads them.
The GitHub session's sub becomes that numeric id, with the login kept for
display and logs. This amends SPEC-0013's "subject login" and matches the
identity key ADR-0029 uses: (issuer, subject), linked to an existing user only
by verified, lower-cased email.
Enrollment, not every sign-in
The gate decides whether an identity may become or join a Cairn user. A sign-in is an enrollment when its identity is not yet linked to an active user, and its verified email does not link it to one. The mode applies to every provider, as Switchboard's does.
- Until ADR-0029 introduces persistent users, no identity is ever "already linked", so every GitHub sign-in is an enrollment and is checked. OIDC sign-ins are not gated until then, because Cairn cannot yet tell a returning OIDC user from a new one. This is build order, not a transition window: the gate covers OIDC in the release that adds users.
- Afterwards, an existing user is never locked out by the allowlist. To revoke access, the operator suspends the user (ADR-0029). This matches Switchboard. It also means a GitHub API outage cannot lock out people who already have accounts.
Fail closed
In allowlist mode with an empty allowlist, and in invite mode before
invitations exist, no GitHub identity can enroll. While no GitHub sign-in could
succeed (true before ADR-0029 under the default invite mode, unless the
operator sets allowlist with entries, or open), the provider is treated as
unconfigured:
- no login button;
/auth/login?provider=githubreturns 404, indistinguishable from an unknown provider (SPEC-0013);- cairnd logs a startup warning naming the variables that would enable it.
Check order in the callback
After identity verification (unchanged), and before a session is created:
- Is the identity already linked to an active user? Admit. (This applies only after ADR-0029.)
- Is
CAIRN_ENROLLMENT_MODE=open? Admit. - In
invitemode: is the identity an operator, or does its verified email hold a pending invitation? Admit. Otherwise deny. - In
allowlistmode: does a subject, email or@<domain>entry match? Admit. - For each
github-org:entry,GET /user/memberships/orgs/{org}with the user's token:200withstate: "active"admits;404(not a member) orstate: "pending"means try the next organisation;403means the organisation restricts third-party OAuth apps and has not approved Cairn's app. This is logged with its own reason, because the fix is an organisation owner approving the app, and the next organisation is then tried;- any other response, or a network error, denies. Fail closed.
- Otherwise, deny.
A denial returns a generic 403 page ("This GitHub account is not permitted on this Cairn instance") and creates no session. The page reveals nothing about which organisations, users, teams or invitations exist. The log records the GitHub login, the numeric id and the reason, never the token.
Scopes
read:org is requested only when the mode is allowlist and
CAIRN_ENROLLMENT_ALLOW holds a github-org: entry. Every other instance keeps
read:user user:email, so the consent screen matches what the instance
actually checks.
Security and tenancy
- The enrollment mode and allowlist are operator configuration. No user, team admin, token or request can change them.
- The gate controls who may have an account. What an account may do (teams, ownership, visibility) is ADR-0029's concern.
- Linking by verified email only is safe for enrollment, because a verified email proves control of the address. An allowlisted identity cannot acquire another user's account unless it controls that user's verified email.
- Offboarding is suspension, not re-checking. Someone who leaves an allowed organisation keeps their account until the operator suspends them. After ADR-0029 this is a deliberate trade for cross-product consistency and outage-resilience (option B was rejected), and the operator guide says so.
How it composes with Switchboard and Harness
- Switchboard uses the same variables (
SWITCHBOARD_ENROLLMENT_MODE,SWITCHBOARD_ENROLLMENT_ALLOW), mode names, default and allowlist syntax, and the same "gate enrollment, suspend to revoke" rule (Switchboard SPEC-0033). An operator configures both products with one mental model. Switchboard's GitHub login design carried the same "allow-list?" open question, and its SPEC-0033 answers it. - Harness is unaffected. Harness agents authenticate to Cairn with MCP OAuth or tokens, never with GitHub login.
Consequences
- Good, because enabling GitHub login can no longer admit strangers by omission. Open signup is an explicit, auditable setting.
- Good, because "my org" is one entry (
github-org:acme), and "these people" pins immutable ids. - Good, because operators meet the same enrollment model in Cairn and Switchboard.
- Good, because
read:orgis asked for only when it is used. - Good, because the GitHub login docs can now be published, with a safe configuration as the documented default.
- Bad, because once persistent users exist (ADR-0029), leaving the organisation does not revoke Cairn access. The operator must suspend the user.
- Bad, because organisation checks depend on the organisation's OAuth app policy. An organisation with third-party restrictions must approve Cairn's app before its members can enroll. The 403 is logged distinctly, and the guide explains the fix.
- Bad, because a deployment that relied on #259's open behaviour stops admitting
GitHub users until it sets
CAIRN_ENROLLMENT_MODE=allowlistwith entries, oropen. No such deployment is documented, because the variables were never published. The startup warning names exactly what to set. - Bad, because once users exist, the default
invitemode on an instance that offers GitHub also gates new OIDC users. An operator who adds someone to their IdP either invites them or sets another mode. Switchboard has the same rule. - Bad, because
subchanges from the login to the numeric id for new GitHub sessions. Sessions are short-lived, and the migration is to let old sessions expire.
Confirmation
Callback tests run against a fake GitHub. They cover:
- an allowlisted
https://github.com|<id>entry after a simulated rename; - verified-email and
@<domain>entries; - an active organisation member, a pending member, a non-member (404), and a restricted organisation (403);
- a network error on the membership call, which must deny;
openmode;- the default
invitemode with GitHub configured and no invitations, where the provider must be absent and startup must succeed with a warning; allowlistmode with an empty allowlist, where the provider must be absent.
A test also asserts that read:org appears in the authorisation URL if and only
if a github-org: entry is configured in allowlist mode. The existing token-containment tests must still
pass, with no token appearing in logs on the denial paths.
Pros and Cons of the Options
A. Enrollment modes plus allowlist, gate enrollment, fail closed (chosen)
- Good, because it maps onto how GitHub-using teams are organised, and onto Switchboard's model.
- Good, because a GitHub outage cannot lock out existing users.
- Bad, because it needs one extra GitHub API call per organisation at enrollment,
and the
read:orgscope when organisations are used. - Bad, because offboarding requires an operator action (suspension).
B. Re-check the allowlist on every sign-in
- Good, because leaving the organisation revokes access at the next login, with no operator action.
- Bad, because a GitHub API error at login locks out every existing user of an org-gated instance (fail closed cuts both ways).
- Bad, because it diverges from Switchboard's enrollment rule, so an operator running both products gets two behaviours for one configuration.
- Neutral, because an existing session outlives the membership change either way. Only the next login differs.
C. Email-domain allowlist
- Good, because it needs no extra scope and no extra API call.
- Bad, because a verified email proves control of an address, not membership of anything. Former employees keep verified addresses on personal accounts, and many organisations' members use personal email addresses on GitHub.
- Neutral, because it is rejected as the mechanism, not forbidden:
CAIRN_ENROLLMENT_ALLOWaccepts@<domain>entries for parity with Switchboard, and an operator who uses one accepts this weakness, which the guide states beside it.
D. Keep it open
- Good, because it needs no code.
- Bad, because the safe state depends on every operator reading a warning, and the unsafe state is the default. That is the inverse of the driver.
Architecture Diagram
flowchart TD
CB["/auth/callback?provider=github"] --> V[verify state, exchange code,<br/>GET /user + /user/emails<br/>ADR-0019, unchanged]
V --> L{identity linked to an<br/>active user? ADR-0029}
L -- yes --> SESS[create session]
L -- no, enrollment --> M{CAIRN_ENROLLMENT_MODE}
M -- open --> SESS
M -- invite --> INV{operator, or a pending<br/>invite for the email?}
INV -- yes --> SESS
INV -- no --> DENY
M -- allowlist --> U{subject, email or @domain<br/>in CAIRN_ENROLLMENT_ALLOW?}
U -- yes --> SESS
U -- no --> ORG{each github-org: entry:<br/>GET /user/memberships/orgs/org}
ORG -- 200 active --> SESS
ORG -- 404 / pending / 403 --> ORG
ORG -- other error --> DENY
ORG -- none left --> DENY[generic 403, no session,<br/>log login + id + reason]
More Information
- Extends ADR-0019. This reverses SPEC-0013's design default ("any verified-email GitHub account may log in") and resolves that open question. SPEC-0013 is amended in the same change (new requirements AL-1 to AL-6).
- SPEC-0013 is the GitHub login spec's number on
main. The renumbering infix/spec-0013-collisionmerged as #257 and moved embedded docs to SPEC-0015, so SPEC-0013 is stable, and it is amended rather than replaced. - Related records (front-matter edges): Cairn ADR-0029 / SPEC-0023 (Teams
and tenancy: users, identities, invitations, suspension). Cross-repo, cited
in prose: Switchboard SPEC-0033 (teams and tenancy, including
SWITCHBOARD_ENROLLMENT_MODEandSWITCHBOARD_ENROLLMENT_ALLOW, aligned in its PR #357). - Design review, Joe, 2026-09-22: the mode is operator config,
CAIRN_ENROLLMENT_MODE, defaulting toinvitewhen GitHub login is configured, with the same values and semantics as Switchboard. There are no deprecation windows or back-compat shims. - Design review, 2026-09-22, resolution: someone who leaves an allowed organisation keeps access until an operator suspends them. That is the v1 rule, and it is the same as Switchboard's.
- GitHub REST: "Get an organization membership for the authenticated user"
(
GET /user/memberships/orgs/{org}, which returnsstateofactiveorpending, or 404 when the user is not a member).