Skip to content
andrew.dunn.dev

Enabling Editors to Use Git Without Knowing

The school my kids attend was migrating off WordPress, and the editor flow became the part I spent the most time on. The old site was a plugin-heavy monolith that a small school with no IT staff is the wrong place to be running. The migration goal was something more maintainable and less concerning from a supply-chain standpoint: fewer moving parts, fewer auto-updating dependencies in the request path, content versioned in git where every change has a name attached. The technical asks were modest: let educators write blog posts and update classroom pages without involving a developer for each change. The site itself is a few-thousand-line Astro project on Cloudflare Pages.

The standard advice is: pick a git-backed CMS (Decap, Sveltia, Tina), wire its OAuth proxy to the git host, every editor gets an account on the git host, every save is a commit by that editor.

That advice doesn’t work here. Most of the school’s educators are in Google Workspace, which is where their email, calendar, and shared drive live. They don’t have GitLab accounts. Asking them to create one, manage credentials, accept invitations to the project, and remember which password is which is a non-starter. So is paying for a hosted CMS like Sanity or Contentful, partly on cost and partly because the editor UI lives on a third-party domain that won’t survive a vendor change. The school wants control of where the words live and who’s allowed to change them, and the engineering for it has to be cheap, frictionless for non-technical editors, and simple enough that the next volunteer can maintain it.

What we wanted was a fourth path that the canonical tutorials don’t describe.

The mismatch

The default CMS auth model assumes a 1:1 mapping between editor and git-host user. The OAuth flow goes editor → git host (via a relay), and the editor ends up holding a token tied to a real account on the host. Every commit is authored by that account; permissions come from the host’s project membership. Clean, well-understood, and exactly wrong for our shape.

DEFAULT MODEL · 1:1one seat eachPEOPLEeditorsGIT HOSTaccountsN editors = N git-host seatsTRANSLATED IDENTITY · N:1PEOPLEeditorsWORKERtranslatorverify identity, allowlistone PATGIT HOSTone botsingle seatN editors = 1 git-host seat

One editor, one account is the default: four editors mean four seats at the git host, four invitations, four passwords to remember. Translated identity breaks the mapping by putting a translator in the middle that verifies who the editor is, so the same four editors cost a single bot seat.

What we needed was something more like:

  • Editors sign in with our identity (the school’s Google Workspace), and the system verifies they’re from the school’s domain.
  • A single bot account at the git host holds the only credential that ever touches the API.
  • Commits show the editor’s name and email in git log, even though the bot is the agent that recorded them.
  • Per-editor write rules (“teachers can save blog posts; admins can save anything”) live on our side, not as git-host project membership.

I went looking for prior art. The two patterns closest in shape are vencax/netlify-cms-github-oauth-provider (the canonical OAuth provider for Decap CMS) and sveltia/sveltia-cms-auth. Both relay the OAuth dance to a git host. Neither does the identity translation we needed: Decap’s callback.js returns the editor’s own git-host token verbatim to the CMS, and Sveltia’s handleCallback does the same. Neither has an authorization hook, a token-swap callback, or an author-injection point. They’re built for the 1:1 model.

The pattern

I’ll call it translated identity. Editor identity in (verified by an OIDC IdP your org already runs); attributed git history out (without the editor ever touching the git host). The translation happens in a small relay we wrote as a Cloudflare Worker. The deployed instance at the school is private; the publishable distillation lives at andrewdunndev/sveltia-identity-translator and is what this post is really about.

sign-inredirectid_tokenmint24h JWTfrom JWTwritesreadsTRANSLATOR · WORKEREDITORSveltia /admin/Workspace accountno git-host accountSTEP 1/oauth/authorize/callback, verify hd claimSTEP 2JWT mint (HS256)sub, email, name; 24h expSTEP 3/gitlab/* REST proxyallowlist → PAT → author_*STEP 4/gitlab/graphqlread-only; mutations → 403STEP 5synthesized identity/user, /members/all/0IDENTITY PROVIDERGoogle consentOAuth 2.0 + OIDCGIT HOSTGitLab api/v4single bot PAT

One worker, five jobs. Google proves the editor belongs to the school’s domain and the translator mints a 24-hour JWT of its own; every save then comes back to the same worker, where the REST proxy checks the allowlist and swaps in the bot token, GraphQL is allowed to read and nothing else, and the identity endpoints answer from the JWT without ever calling the git host.

The translator does five jobs. Each is a paragraph on its own; each has a “why this way” that turns out to matter.

1. OAuth handshake

OAuth 2.0’s authorization-code flow is the standard “redirect the user to the IdP, the IdP redirects them back with a one-shot code, the server exchanges the code for an access token” dance. The translator implements the boring half (it’s ~150 lines) and is unremarkable except for one thing: the hd claim is checked twice.

hd is Google’s hosted-domain claim, the field marking which Workspace domain the user belongs to. The translator includes hd=example.org as a query param at authorize time, which scopes Google’s account chooser to accounts in that domain, and verifies the same field on the userinfo response. Those two checks are not equivalent. The URL parameter is a UI hint; a user with both a personal @gmail.com and a @example.org account can still pick the personal one. The userinfo claim is the cryptographic boundary. Removing the URL hint is a UX regression. Removing the userinfo check is a security bug.

2. JWT mint

JSON Web Tokens are signed, base64’d blobs carrying claims. The translator mints a 24-hour HS256 (HMAC with SHA-256) token after a successful OAuth handshake, signed with a server-side secret. HS256 is symmetric, which would be a problem if the verifier were a different service from the issuer; it isn’t here. There’s exactly one issuer and one verifier (the same Worker), so the public-key distribution problem that motivates RS256 doesn’t exist. The token carries sub, email, and name, enough for everything downstream.

3. REST proxy

Every API call from Sveltia goes through /gitlab/*. The Worker validates the JWT, consults an allowlist, swaps the editor’s JWT for the service-account Personal Access Token (PAT), and forwards. A PAT is GitLab’s API credential tied to a user account; the leverage of the translator is that one bot service-account holds one PAT, and every editor’s request flows through it server-side. Editors never touch the PAT or the bot account.

On commit POSTs, the translator injects author_name and author_email into the request body so the editor’s identity survives onto the commit. This is the trick. More on it in the next section.

4. GraphQL passthrough, read-only

Sveltia uses GraphQL for all reads (file tree, blobs, default branch) and REST for writes. The upstream comment in commits.js is explicit: “the commitCreate GraphQL mutation is broken and images cannot be uploaded properly, so we use the REST API instead.” The translator forwards GraphQL queries verbatim and rejects any body containing the mutation keyword with a 403, on the principle of failing closed: when an input is ambiguous or unrecognized, deny rather than allow. The regex catches mutation, mutation Foo, mutation { ... } anywhere in the query string.

5. Synthesized identity endpoints

Two GitLab API endpoints don’t get forwarded; the translator answers them from the JWT directly. GET /user returns the editor’s identity (the PAT’s userinfo would be the bot, which is wrong). GET /projects/<repo>/members/all/0 returns a fake Maintainer membership (Sveltia checks repo access by user-id after fetching /user; with our synthesized id: 0, the upstream call 404s). These are mechanical requirements of the model.

Authorization is a pluggable hook: authorize(identity, repo, method, path) → bool. The default impl is a YAML allowlist (editors.yml) checked into the relay’s repo with per-(email, repo, path-glob) tuples; default-deny. A teacher whose globs are src/content/blog/** can save blog posts, can’t save anything else, can read what Sveltia exposes in the UI. The hook shape is what matters; the YAML is one implementation.

The unlock: author and committer

Git tracks two identities per commit: author (who wrote the change) and committer (who recorded it). Usually they’re the same person. Translated identity uses the split deliberately: editor as author, bot as committer. git log shows who wrote what; the bot is just the agent that did the API call.

injectedCOMMIT a3f9c12AUTHORMs. Garcíam.garcia@example.orgCOMMITTERcms-botcms-bot@example.orgUpdate homecoming datessrc/content/blog/homecoming-2026.mdREQUEST BODY+ author_name+ author_emailPAT OWNERimplicit committeron every API call

The split identity is what keeps attribution honest. The translator injects author_name and author_email from the JWT claims, so git log names the editor who wrote the change, while committer stays the service account whose token made the call.

The mechanic that makes this work is a GitLab REST endpoint quirk: POST /repository/commits accepts top-level author_name and author_email fields that override what the calling user would otherwise be. The translator slips those fields into the body before forwarding. GitLab honors them on the commit object; committer defaults to the PAT owner.

GraphQL’s commitCreate mutation has no equivalent override field. Commits made via GraphQL attribute to the authenticated user, period. That’s why the GraphQL handler is read-only: routing writes through it would silently break editor attribution. Failing closed on mutation keeps the model honest until upstream commitCreate gains an author override.

If GraphQL ever does grow that override, the translator loosens. Until then, REST-only writes is the price of correct attribution.

What’s load-bearing

A few non-obvious choices that took thinking-through and aren’t derivable from “it’s a proxy.”

Why one Worker for many sites. The relay is multi-tenant by construction. A second consumer site (a sister blog, a documentation site, anything Sveltia-driven) is a Sveltia config drop and one or more entries in editors.yml. No new Worker, no new domain, no new secrets.

SITE ASveltia /admin/your-org/main-siteSITE BSveltia /admin/your-org/docsSITE CSveltia /admin/your-org/docs-privateONE WORKERtranslatorone OAuth client, one JWT secretEDITORS.YMLa@org → main-siteb@org → docsc@org → docs-privateGIT HOSTsingle bot PATall three sites

One Worker fronts every site. A second site is a Sveltia config drop and a line in editors.yml, with no new Worker, domain, or secret, and write access stays scoped per identity, repo, and path.

An in-repo proxy (one Worker per site) is per-site by construction; every new site doubles the auth surface to operate, monitor, and rotate secrets across. The relay keeps the auth surface consolidated. The boundary is per-(identity, repo, path-glob), so cross-site access is explicit: editor@example.org lists each repo separately, and a typo in one entry can’t accidentally unlock a different site.

Why JWT is 24h with no refresh. Long enough that an editor’s saving session never re-authenticates mid-flight. Short enough that a stolen JWT expires within a working day.

Refresh-token machinery would gain rotation but cost code and a flow we don’t need at this scale. Force-revoke all sessions by rotating JWT_SECRET (wrangler secret put JWT_SECRET).

Why editors.yml lives in the relay’s repo, not a database. Versioning the allowlist in git gives blame, review, and rollback for free. Adding an editor is a commit; CI runs and any parse error surfaces before deploy. There’s no separate database to back up, schema-migrate, or authenticate against. The Worker imports the file as a text resource at module init, parses it once, and pre-compiles all path globs. Per-request authorization is a synchronous map lookup + regex test on already-compiled patterns.

What didn’t work

Per-collection visibility doesn’t exist. Sveltia’s config.yml is global: every signed-in editor sees the same collections. We labelled the admin-only collections (Administrative) as a UX hint, but the actual gate is the relay’s allowlist. A teacher who clicks into Staff Bios and tries to save gets a 403 from the relay. That’s a confusing UI. The local workaround is multiple admin paths (/admin/teacher/, /admin/admin/, each with its own scoped config.yml). The cleaner shape is a per-collection visible_to predicate in Sveltia itself, keyed off identity claims from the auth response, so the UI scopes what each editor sees. We’ll be proposing it upstream. With 1-3 distinct edit roles right now, the trust+403 model holds.

What we’d contribute back

The relay is small (~700 lines of TypeScript, plus tests). Three contributions are planned, gated on real in-prod soak. Filing speculative PRs against unobserved code wastes the maintainer’s time and ours.

WhereWhatStatus
sveltia/sveltia-cms-authDiscussion: “translated-identity mode” (IdP-as-config + authorize hook + author injection on commits + GraphQL mutation rejection)Planned, after ~1 week of in-prod editor traffic
sveltia/sveltia-cmsIssue: investigate the broken commitCreate GraphQL comment in commits.js. Author override would unlock end-to-end GraphQLPlanned, file after the auth conversation has signal
sveltia/sveltia-cmsFeature request: per-collection visible_to predicate keyed off identity claims, so role-based UI gating doesn’t require multi-admin-path configsPlanned, when we hit real UX friction

The first one is the load-bearing contribution. If it lands, “translated identity” becomes a published auth mode any small org with editors-in-Workspace-but-not-on-the-git-host can adopt without writing a relay. The shape upstream gets is config-as-config (provider_url, client_id_env, hd_claim_field) and policy-as-hook (a function the deployer registers); our YAML allowlist is one impl shipped as an example.

Generalizing

I keep coming back to the meta-pattern: when a framework’s auth model assumes the wrong thing, write the proxy, contribute back. The school could have:

  • Forked Sveltia and modified its OAuth client to talk to Google directly. (Maintains a fork; loses upstream improvements.)
  • Adopted a hosted CMS. (Cost; vendor lock; editor UI on someone else’s domain.)
  • Given every educator a git-host account. (Seat cost; ongoing user-management overhead; passwords; reviews.)
  • Stayed on WordPress. (The flat tax of running WordPress is meaningful, and the actual school’s WordPress was creaking.)

The relay path costs ~700 LOC of code we own, runs on Cloudflare Workers’ free tier, and the work-product we own is upstream-shaped from day one. The bet is that another school, nonprofit, or club hits the same shape and we save them the writing. If the upstream maintainer has appetite, the second adopter doesn’t need to write any of it.

Thanks to Andrew DeJong (@adejong5) for collaboration on this project.

If you’re in a similar shape (small org, editors in Workspace, Microsoft, or Okta, static site with a git-backed CMS), the translator code, smoke harness, and end-to-end deploy walkthrough are at andrewdunndev/sveltia-identity-translator. The README walks through Google Cloud Console, GitLab service-account setup, Cloudflare Worker deploy, and Sveltia config wiring; about 30 minutes end-to-end if you’ve used these tools before. Lift what’s useful.