Skip to content

Make authentication a plug-in, and move our Clerk wiring out - #121

Merged
NewtonSander merged 5 commits into
mainfrom
feature/identity-provider-seam
Aug 19, 2026
Merged

Make authentication a plug-in, and move our Clerk wiring out#121
NewtonSander merged 5 commits into
mainfrom
feature/identity-provider-seam

Conversation

@NewtonSander

Copy link
Copy Markdown
Collaborator

Authentication becomes a plug-in. Multi-tenancy stays here; knowledge of how a
caller proves who they are does not.

With no provider installed the server is single-tenant: every request resolves to
the synthetic local group. That is the configuration a clone gets, and it is why
the platform needs no built-in implementation of the contract.

Our Clerk wiring moves to glossogen-deploy, which is where what production runs
already lives. See its companion pull request.

What a provider gets, and what it never sees

A provider is a separate distribution declaring one entry point under
glossogen.identity_provider.v1. The middleware keeps the parts a provider must
not get wrong: it extracts the bearer credential, parses the URL's group slug, and
resolves that slug to a groups row before calling resolve_identity. So a
provider never queries that table and cannot get tenancy isolation wrong. It
answers one question, and raises IdentityRejected with 401 for a credential that
does not verify or 403 for one that does not cover the group.

Ambiguity refuses to boot. Two declared providers, or one declared under a
contract version this platform does not read, raises. The scenario and metric
loaders warn and carry on in the same situation, which is right for them: a missing
scenario is a missing feature. A missing auth provider is a server that
authenticates nothing while an operator believes it is protected.

Frontend

auth-adapter.ts is the contract; adapter/ is the implementation, and the copy
here answers "not configured" to every slot. Four modules, not one object, because
React's module graph forbids one: readSession needs a server-only import,
AuthProvider is a client component, getSessionToken runs in the browser with no
React so api-client.ts stays importable from either side, and the proxy delegate
runs in the edge runtime.

/sign-in, /sign-up, /select-org and /mcp-consent stay as shells because the
App Router resolves pages by file path. CLERK_PUBLISHABLE_KEY becomes
AUTH_PUBLIC_PUBLISHABLE_KEY, and RuntimeConfig gains an auth map rather than a
named field. The request-time read is unchanged, so one image still serves any
environment.

Verification

  • make lint-server, make test (657 pass, 30 new), make check-frontend,
    make docs-build --strict all clean
  • make gen-api-types produces no diff against the committed schema
  • Frontend builds with no provider configured
  • The Clerk path was built end to end: platform source plus the deploy repo's
    adapter overlay compiles, type-checks, and its proxy chunk carries both
    organizationPatterns, so multi-group URL routing survives
  • A real out-of-tree provider was installed and exercised: it resolves through
    the entry point, mounts its routers, and answers 401 on a bad credential, 404 on
    an unknown slug, 200 on /api/health

Two bugs this caught

Migration revision ids must stay under 32 characters. alembic_version.version_num
is varchar(32), and 0004_add_evaluation_content_hash is exactly 32. A longer id
applies the schema change and then fails the version bookkeeping, leaving the
database migrated but recorded one revision behind.

groups.clerk_org_id TEXT UNIQUE carries an auto-generated constraint that
ALTER TABLE ... RENAME COLUMN does not rename. Migration 0005 renames the
column, the index and the constraint, and round-trips on a scratch database.

Merging this cuts a release

The label decides what ships. Merging publishes Clerk-free backend and frontend
images, which is why the promoter in glossogen-deploy is currently disabled: it
must not pin an unauthenticated frontend into production. Re-enable it only after
that repo's pins point at the derived image.

AUTH_PUBLIC_PUBLISHABLE_KEY is already set on the production frontend service
alongside the old variable, so the rename is staged rather than a cutover.

🤖 Generated with Claude Code

NewtonSander and others added 3 commits August 18, 2026 16:57
The public repo carried the authentication setup of one hosted instance. Anyone
cloning it read that as part of the platform, when it described how we host it.

Multi-tenancy stays here: the groups table, /api/g/{slug}/ routing, and the
Identity attached to every request. What leaves is any knowledge of how a caller
proves who they are. A deployment supplies that as a distribution declaring one
entry point under glossogen.identity_provider.v1.

With no provider installed the server is single-tenant: every request resolves to
the synthetic local group. That is why there is no built-in implementation of the
contract, and why nothing in it is optional.

The middleware keeps the parts a provider must not get wrong. It extracts the
bearer credential, parses the URL's group slug, and resolves that slug to a groups
row before calling resolve_identity, so a provider never queries that table and
cannot get tenancy isolation wrong. It answers one question: does this credential
grant access to this group, and as whom.

Ambiguity refuses to boot. Two declared providers, or one declared under a
contract version this platform does not read, raises. The scenario and metric
loaders warn and carry on in the same situation, which is right for them: a
missing scenario is a missing feature. A missing auth provider is a server that
authenticates nothing while an operator believes it is protected.

Deferred OAuth consent stays platform code. Parking a request is about having more
than one group to choose from, not about any one vendor, and approve_pending_consent
already took a resolved group_id. Only the approval endpoint is pluggable.
/mcp/whoami stays too, since the CLI calls it in either mode.

create_app() exists so a test can build an app around a stub provider, and so the
export script can pin the provider off. That second one matters: export_openapi.py
imported the module-level app, so a developer with a provider installed would have
had `make gen-api-types` quietly add its routes to the committed schema and break
check-api-types for everyone else.

groups.clerk_org_id becomes external_org_id. The column holds the id a group
carries in whichever provider a deployment configures, so naming it after one made
the schema describe a deployment choice. Migration 0005 renames the column, its
index, and the auto-generated UNIQUE constraint, which RENAME COLUMN leaves behind.
Its revision id is under 32 characters because alembic_version.version_num is
varchar(32) and a longer id applies the change then fails the bookkeeping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Next.js resolves pages by file path, so /sign-in, /sign-up, /select-org and
/mcp-consent cannot live outside this repo. They stay as shells that render one
adapter component each and own nothing else. src/proxy.ts stays for the same
reason and keeps its matcher as a literal, because Next reads it statically and
rejects a re-exported one.

The contract is auth-adapter.ts; the implementation is adapter/, and the copy here
answers "no provider configured" to every slot. Replacing that directory is the
whole integration surface.

Four modules rather than one object, because React's module graph forbids one.
readSession needs a server-only import, AuthProvider is a client component,
getSessionToken runs in the browser with no React so api-client.ts stays
importable from either side, and the proxy delegate runs in the edge runtime. No
single module can be imported from all four places.

The no-op components declare the props they ignore. A component taking no props is
assignable to ComponentType<P>, so the conformance assertion at the foot of each
module accepts a zero-prop version while every call site fails to type-check. tsc
caught that; the assertion did not.

CLERK_PUBLISHABLE_KEY becomes AUTH_PUBLIC_PUBLISHABLE_KEY, and RuntimeConfig gains
an auth map collected from every AUTH_PUBLIC_* variable rather than a named field,
since the platform cannot know what values a provider needs. The request-time read
is unchanged, so one compiled image still serves any environment. Those values
reach the browser and are visible in page source; an adapter's secrets stay on the
server and never enter that object.

The MCP consent page splits along the same line as the backend. The endpoint, its
payload and the approve panel are our API and our copy, so they stay. Only "who is
this, and which group" goes to the adapter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs named one identity provider ninety times, in the env tables, the
architecture overview, the deployment guide and the security notes. Someone
self-hosting had to read past all of it to find out that authentication is
optional and pluggable.

The two modes are now single-tenant, meaning no provider installed, and
multi-tenant, meaning one is. Both halves of the contract are documented in
docs/web-ui.md under the existing Authentication heading, so every inbound anchor
keeps working and mkdocs needs no nav change.

CHANGELOG.md keeps its mentions. Those entries describe what a shipped release
did, and rewriting them would make the log lie. Migrations 0001 and 0003 keep
theirs for the same reason: they record SQL that already ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the release:patch Merging cuts a patch release (0.1.2 -> 0.1.3) label Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  src/glossogen/server
  app.py 8-15
  app_factory.py 43-52, 61-74, 87-108, 186
  error_logging_handlers.py
  feature_flags.py
  src/glossogen/server/identity
  bootstrap.py 24-25
  middleware.py 104-105, 140
  provider_services.py 28-76
  src/glossogen/server/mcp
  asgi_context.py
  browser.py
  in_memory_oauth_storage.py
  models.py
  oauth_mounting.py 32, 35, 47, 57-59, 70-107, 125-142
  oauth_provider.py 63-65, 106-107, 129-130, 144-145
  oauth_records.py
  oauth_storage.py 272, 280-281
  run_context.py
  whoami_router.py 40-62
  src/glossogen/server/pdf
  export_data.py
  html_renderer.py
  router.py
  src/glossogen/server/runs
  branch_sources.py
  derived_run_references.py
  detail_reader.py
  router.py
  src/glossogen/server/scenarios
  router.py
Project Total  

The report is truncated to 25 files out of 37. To see the full report, please visit the workflow summary page.

This report was generated by python-coverage-comment-action

Vulture reads only src/, scripts/ and linter/, so a function whose sole caller is
an installed identity provider looks dead. Three are:
soft_delete_group_by_external_org_id, resolve_frontend_url, and
approve_pending_consent. All three are platform API a provider is expected to call,
so they are whitelisted rather than deleted.

Regenerated over the same paths the lint targets check, per the pre-commit
checklist: doing it over a narrower set drops the entries covering the ones it left
out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread vulture_whitelist.py
depends_on # unused variable (src/glossogen/db/migrations/versions/0004_add_evaluation_content_hash.py:25)
upgrade # unused function (src/glossogen/db/migrations/versions/0004_add_evaluation_content_hash.py:28)
downgrade # unused function (src/glossogen/db/migrations/versions/0004_add_evaluation_content_hash.py:32)
revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:20)
Comment thread vulture_whitelist.py
upgrade # unused function (src/glossogen/db/migrations/versions/0004_add_evaluation_content_hash.py:28)
downgrade # unused function (src/glossogen/db/migrations/versions/0004_add_evaluation_content_hash.py:32)
revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:20)
down_revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:21)
Comment thread vulture_whitelist.py
downgrade # unused function (src/glossogen/db/migrations/versions/0004_add_evaluation_content_hash.py:32)
revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:20)
down_revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:21)
branch_labels # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:22)
Comment thread vulture_whitelist.py
revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:20)
down_revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:21)
branch_labels # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:22)
depends_on # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:23)
Comment thread vulture_whitelist.py
down_revision # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:21)
branch_labels # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:22)
depends_on # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:23)
upgrade # unused function (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:39)
Comment thread vulture_whitelist.py
branch_labels # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:22)
depends_on # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:23)
upgrade # unused function (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:39)
downgrade # unused function (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:50)
Comment thread vulture_whitelist.py
depends_on # unused variable (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:23)
upgrade # unused function (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:39)
downgrade # unused function (src/glossogen/db/migrations/versions/0005_rename_external_org_id.py:50)
soft_delete_group_by_external_org_id # unused function (src/glossogen/db/queries.py:98)
Comment thread vulture_whitelist.py Fixed
Comment thread vulture_whitelist.py
health # unused function (src/glossogen/server/app.py:241)
server_config # unused function (src/glossogen/server/app.py:247)
resolve_frontend_url # unused function (src/glossogen/server/frontend_url.py:11)
health # unused function (src/glossogen/server/health_router.py:17)
Comment thread vulture_whitelist.py
server_config # unused function (src/glossogen/server/app.py:247)
resolve_frontend_url # unused function (src/glossogen/server/frontend_url.py:11)
health # unused function (src/glossogen/server/health_router.py:17)
server_config # unused function (src/glossogen/server/health_router.py:23)
…state

Two findings from review, both real.

The provider-facing surface was undeclared. IdentityProvider says what a provider
gives the platform; nothing said what the platform gives back, and that set was
exactly what vulture had just flagged as dead. The sharpest case was
approve_pending_consent, the one call a provider must make to finish a deferred MCP
authorization: reachable only by reaching into request.app.state.oauth_provider,
mentioned in no docstring, discoverable only by reading a file this branch deleted.

provider_services.py is now that half of the seam. approve_parked_consent wraps the
app.state reach-in so a provider never touches it, which also gives
approve_pending_consent an in-tree caller and takes it out of the whitelist.
frontend_base_url absorbs the orphaned frontend_url module, whose docstring claimed
two in-tree readers that did not exist. The two groups query helpers keep their home
in queries.py and are named in the module docstring, so the surface is one list
rather than four discoveries. The three entries still in the whitelist are declared
API with a stated reason, not residue.

Whitelisting alone would have papered over that, which was the reviewer's point.

The frontend had two disagreeing answers to "is auth configured". runtime-config
counted AUTH_PUBLIC_* variables while the adapter knew what it actually exported,
and the route gates read the env-var answer. An adapter installed but keeping its
configuration server-side, or missing one variable, skipped the /g/<slug> sign-in
redirect and pointed the landing CTA at the local workspace while the backend
rejected every call. Not a hole, since the backend still enforces, but a symptom
nowhere near its cause.

AuthSession gains `configured`, so the adapter answers once and the platform stops
inferring. isAuthConfigured is gone rather than left as an invitation to the same
bug.

Also: the MCP-token fallback no longer depends on which status a provider chose.
Gating it on 401 meant a provider that answers 403 for an unrecognised credential
would silently break glossogen push-to-prod against a hosted backend. The lookup is
exact and runs only on an already-failing request, so admitting both costs nothing.

Leftovers: DockerfileFrontend's trailing comment and CLAUDE.md's file inventory both
still named deleted things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread vulture_whitelist.py
model_config # unused variable (src/glossogen/server/mcp/consent_router.py:34)
approve_consent # unused function (src/glossogen/server/mcp/consent_router.py:64)
whoami # unused function (src/glossogen/server/mcp/consent_router.py:135)
frontend_base_url # unused function (src/glossogen/server/identity/provider_services.py:34)
@NewtonSander NewtonSander added release:minor Merging cuts a minor release (0.1.2 -> 0.2.0) and removed release:patch Merging cuts a patch release (0.1.2 -> 0.1.3) labels Aug 19, 2026
@NewtonSander
NewtonSander merged commit dfb0d8e into main Aug 19, 2026
11 checks passed
@NewtonSander
NewtonSander deleted the feature/identity-provider-seam branch August 19, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:minor Merging cuts a minor release (0.1.2 -> 0.2.0)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant