Skip to content

Add scoped email login to the external gateway - #331

Open
qiuethan wants to merge 6 commits into
stagingfrom
feat/gateway-email-login
Open

qiuethan wants to merge 6 commits into
stagingfrom
feat/gateway-email-login

Conversation

@qiuethan

@qiuethan qiuethan commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes

Adds opt-in “Sign in with Misty” for active directory members. The gateway owns challenge state and admission limits; private verification generates, delivers, and verifies codes; the compute platform owns its sessions.

The external endpoints are POST /v1/auth/email/start, POST /v1/auth/email/confirm, GET /v1/auth/issuer, and GET /v1/auth/people/{person_id}/status, protected by separate auth:email and auth:status scopes. Start responses are generic, challenges are consumer-bound, eligibility is rechecked at confirmation, and redemption is atomic and single-use. Successful responses attest the gateway environment.

Three guesses per challenge and shared mailbox delivery limits bound guessing while preserving other delivered codes. A known pre-send verification cooldown permits one deferred retry using the same challenge and reservation; ambiguous failures never retry. Status and edge-IP budgets are configured together with startup validation.

The security follow-up adds:

  • A single conditional SQL update for attempt admission, checking consumer, expiry, redemption, and remaining guesses while retaining the advisory lock. The guessing cap is enforced by the update itself.
  • A persistent 20-starts/hour client-IP limit, alongside the existing minute, app, and mailbox limits, so two clients cannot exhaust the app's hourly start budget. IPv4-mapped addresses normalize to IPv4; native IPv6 addresses share a /64.
  • Pre-authentication IP rejection before body reads, a 4096-byte cap and a five-second total body deadline, and no-store on login responses. HTTP/1 login 408/413/429 responses close their connections so incomplete uploads cannot linger.
  • Generic 422 rejection of NUL and other non-printable email characters before login admission or upstream work.

Why

Provides the identity boundary for the companion compute integration: UTMIST/Mist#125. Compute receives neither directory database access nor private verification credentials.

Zone

This deliberately spans gateway, verification, shared-auth documentation, platform documentation, and CI: gateway needs a separately scoped private verification identity, and their documented service boundary must change together. The security follow-up changes gateway code only.

Changed-file rationale

Paths below are repository-relative. Every changed file is covered; grouped files share the stated purpose.

Files Purpose
services/gateway/contracts/login.py Define strict login DTOs, printable-email validation, secret-wrapped codes, challenge state, and private verification errors/protocol.
services/gateway/contracts/directory.py, services/gateway/contracts/storage.py Define directory lookup and atomic login-state contracts before implementations.
services/gateway/src/login_policy.py Centralize durable request/send/guess budgets and normalized email/IP identities, including the hourly client cap.
services/gateway/src/storage/schema.py, services/gateway/migrations/versions/002_email_login.py Define and reversibly migrate gateway-owned challenge and quota tables.
services/gateway/src/storage/in_memory.py, services/gateway/src/storage/postgres.py Implement equivalent atomic admission, expiry, activation, and redemption; include login schema in readiness.
services/gateway/src/api/routers/email_login.py Implement start/confirm/issuer/status, active-member checks, and post-response delivery with one safe cooldown retry.
services/gateway/src/api/app.py, services/gateway/src/api/middleware.py Register routes, sanitize errors, and order no-store, IP admission, and bounded body reads correctly.
services/gateway/src/api/deps.py Construct and close the private verification client lazily after scope validation.
services/gateway/src/directory/http_client.py Call the existing authenticated directory email/person lookup operations.
services/gateway/src/verification/__init__.py, services/gateway/src/verification/http_client.py Package the verification HTTP client and validate/sanitize its failures and cooldown responses.
services/gateway/src/config.py, services/gateway/src/cli.py Add opt-in configuration, secret-safe startup checks, paired capacity settings, and explicit external scopes.
services/gateway/tests/test_email_login.py, services/gateway/tests/test_verification.py Cover eligibility, replay, scope ordering, generic failures, retry boundaries, issuer attestation, and non-printable inputs.
services/gateway/tests/test_login_boundary.py Regress early IP rejection, stalled/trickling uploads, body-size boundaries, response privacy, and HTTP-version-specific connection closure.
services/gateway/tests/login_storage_cases.py, services/gateway/tests/test_login_storage.py, services/gateway/tests/test_postgres_adapter.py Check shared adapter semantics, concurrency, quotas, restart persistence, and NUL-email rejection without persisted login state.
services/gateway/tests/test_config.py, services/gateway/tests/test_health.py Cover secret-safe capacity/boot validation and probe availability after edge quota exhaustion.
services/gateway/tests/smoke_stack.py, .github/workflows/ci.yml Exercise gateway → directory/verification HTTP with disposable databases and fake mail; build verification before the smoke check.
services/verification/src/config.py, services/verification/src/api/deps.py, services/verification/src/api/app.py Support a separately scoped config-backed gateway key and validate consumer configuration at boot.
services/verification/src/storage/null_keys.py (removed) Remove the obsolete adapter that rejected all scoped consumers.
services/verification/src/api/routers/verification.py, services/verification/tests/test_request_code.py Return a rounded-up pre-send cooldown and test cross-consumer suppression and subsequent delivery.
services/verification/tests/conftest.py, services/verification/tests/test_consumer_auth.py Isolate cached configuration and verify scoped authentication and malformed-config rejection.
services/gateway/.env.example, services/gateway/README.md, services/gateway/docs/API.md, services/gateway/docs/ARCHITECTURE.md, services/gateway/docs/CONTRIBUTING.md, services/gateway/docs/DEPLOYMENT.md Document settings, API/session responsibilities, limits, body deadlines, tests, provisioning, and rollback.
services/verification/.env.example, services/verification/README.md, services/verification/docs/API.md, services/verification/docs/ARCHITECTURE.md, services/verification/docs/DEPLOYMENT.md Document scoped keys, cooldown responses, and retention requirements while preserving the bot bootstrap path.
AGENTS.md, README.md, docs/ARCHITECTURE.md, docs/DEVELOPMENT.md, docs/RAILWAY-DEPLOYMENT.md, packages/auth/README.md Keep schema heads, service ownership, key provisioning, and rollout guidance consistent with email login.

The staging merge preserves the current wiki-oriented README and retirement of historical audit files. File rationale and validation are recorded here instead of restoring services/gateway/docs/CHANGE-AUDIT.md.

How to verify

Security follow-up checks completed locally:

  • Full gateway suite against a fresh disposable PostgreSQL database: 216 passed. Source paths were pinned and asserted to avoid importing another worktree.
  • Gateway Ruff lint/format checks and git diff --check.
  • Gateway migration upgrade → downgrade → upgrade and isolated installed-wheel API/CLI imports.
  • Real loopback HTTP checks using both Uvicorn parsers: production five-second deadline, oversized/throttled uploads, no-store, and socket closure.
  • Additional chunked-upload and Expect: 100-continue probes, malformed Unicode inputs, and concurrent admission in both adapters. With two hourly admissions remaining, 32 simultaneous requests admitted exactly two; rejected requests consumed no app quota.
  • A second security review found no additional actionable defects in the local fixes.
  • Final-attempt concurrency regressions pass in both adapters. A synchronized PostgreSQL test bypasses the advisory lock only in the test to check the SQL guard independently: before the change both attempts 3 and 4 were admitted; afterward only attempt 3 is admitted and the stored counter stays at 3.

The staging merge changes no gateway, verification, or shared-auth implementation/tests, and leaves the Python lockfile unchanged.

Earlier feature validation recorded on this PR includes both service suites with disposable PostgreSQL, migration preservation of existing keys, three-service Docker HTTP smoke, synthetic browser-to-compute login/retirement flows, cooldown collision regressions, and repository-wide make check. Those broader integration checks were not repeated during the security-only follow-up.

Reproduce the HTTP integration with current images:

docker build -t team-tracking:ci -f services/team-tracking/Dockerfile .
docker build -t verification:ci -f services/verification/Dockerfile .
docker build -t gateway:ci -f services/gateway/Dockerfile .
python3 services/gateway/tests/smoke_stack.py

Checklist

  • Branched from and targeting staging; staging conflicts resolved.
  • Affected-service suites, including disposable PostgreSQL, verified.
  • Ruff lint and format checks passed.
  • Service contribution checklists followed.
  • API, configuration, architecture, deployment, and changed-file rationale updated.

Deployment notes

  • Login remains disabled by default (EMAIL_LOGIN_ENABLED=false). Gateway migration 002 adds challenge/quota tables without changing existing key rows.
  • Deploy verification's scoped-key and Retry-After support before enabling gateway login. Provision a distinct vf_ consumer with verification:write; preserve the bot's bootstrap credential.
  • Gateway needs a directory key with people:read and identifiers:read, plus private verification URL/key configuration. Compute receives only a scoped gw_ key with auth:email and auth:status.
  • Set GATEWAY_ENV explicitly. Compute must pin the expected origin/issuer and use reviewed mappings for existing accounts. Keep staging and production credentials/databases separate.
  • Run one gateway replica/worker, schedule verification-row retention cleanup, and size the paired status/edge budgets for shared backend-IP traffic.
  • Users behind the same NAT or IPv6 /64 share the 20-starts/hour budget. Distributed traffic can still exhaust app capacity, and mailbox send limits can still suppress new sends. An already-delivered challenge retains its own guessing budget.
  • Enable and verify Misty in staging before the companion compute integration. Live ingress, real mail delivery, and production account mapping require operator verification.

See the deployment runbook for provisioning and rollback.

@github-actions github-actions Bot added size/xl >= 500 lines changed zone: packages/auth Owned by the packages/auth zone (docs/CODE-OWNERSHIP.md) zone: services/verification Owned by the services/verification zone (docs/CODE-OWNERSHIP.md) zone: docs Owned by the docs zone (docs/CODE-OWNERSHIP.md) zone: .github Owned by the .github zone (docs/CODE-OWNERSHIP.md) zone: root Owned by the root zone (docs/CODE-OWNERSHIP.md) zone: services/gateway Owned by the services/gateway zone (docs/CODE-OWNERSHIP.md) and removed size/xl >= 500 lines changed labels Oct 26, 2026
@github-actions github-actions Bot added the size/xl >= 500 lines changed label Oct 26, 2026
updated = conn.execute(
update(login_challenges)
.where(login_challenges.c.id == challenge_id)
.values(attempts=login_challenges.c.attempts + 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

admit_login_attempt performs the eligibility SELECT and the incrementing UPDATE separately, but the UPDATE does not repeat attempts < MAX_ATTEMPTS (or otherwise lock the row). Under concurrent confirmations when attempts == MAX_ATTEMPTS - 1, both transactions can pass the SELECT, then both increment and return a challenge; one of them can therefore become the fourth admitted guess. Please make the increment conditional/atomic (for example, include attempts < policy.MAX_ATTEMPTS in the UPDATE and check its returned row) so the guessing cap holds under concurrency.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl >= 500 lines changed zone: docs Owned by the docs zone (docs/CODE-OWNERSHIP.md) zone: .github Owned by the .github zone (docs/CODE-OWNERSHIP.md) zone: packages/auth Owned by the packages/auth zone (docs/CODE-OWNERSHIP.md) zone: root Owned by the root zone (docs/CODE-OWNERSHIP.md) zone: services/gateway Owned by the services/gateway zone (docs/CODE-OWNERSHIP.md) zone: services/verification Owned by the services/verification zone (docs/CODE-OWNERSHIP.md)

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

2 participants