{
"command": "pwd"
}/Users/warrenthompson/Source/LoginID/03-engineering-delivery
{
"file_path": "/Users/warrenthompson/Source/LoginID/CLAUDE.md"
}1 # LoginID Take-Home POC — Project Root
2
3 ## Role
4
5 You are the project manager agent for this repository. This file is the shared context every track refers back to. Each of the five track directories below has its own CLAUDE.md that can be worked independently by a scoped subagent, but that subagent should read this root file first for the assignment, the overall narrative, and how its track fits with the other four.
6
7 When you spin up agents or subagents to do work in a track, point them at that track's CLAUDE.md as their brief, and tell them this root file exists for cross-track context. Don't let a track's agent silently duplicate scope another track owns — check the "Owns / does not own" boundaries below first.
8
9 ## What this repository is
10
11 This is a take-home assignment for a LoginID interview, submitted as a full directory. The goal is not just to answer three coding questions — it's to demonstrate capability as an AI architect: how you scope a problem, use AI tooling deliberately, reason about security and data, and produce something that reads as an intentional, well-run engineering effort rather than a quick script.
12
13 Code does not need to work or be fully complete. LoginID has said explicitly they care more about the design and the reasoning behind it, and about which AI tools/frameworks were used and how. Every track should document its reasoning, not just its output.
14
15 ## The assignment (verbatim from LoginID)
16
17 Provide code solutions for the following questions in Go (preferably) or other languages — describe any tool, framework, or AI used.
18
19 1. Code of a database DAO that can store and retrieve `user_profile` (name, address, phone) and `user_credential` (username, method, password), with support for multiple databases (PostgreSQL or CockroachDB, SQLite, etc).
20 2. Design a RESTful API web service to search and retrieve the above user profile data, plus security API authentication.
21 3. Design a service connector to third-party identity providers (referred to generically as ABC or XYZ) to retrieve personal data from vendors, with the following endpoints:
22 - `/auth` — retrieves an `access_token` based on a POST JSON body `{"username": "<string>", "password": "<string>"}`
23 - `/identity` — retrieves user PII (name, phone, address: `street_address`, `locality`, `region`, `postal_code`, `country`) based on a POST JSON body `{"phone": "<string>", "name": "<string>"}`
24
25 Go is the preferred language. Other languages are acceptable if justified.
26
27 ## Track structure
28
29 Five directories, each independently workable, each owning a distinct slice of the problem:
30
31 | Directory | Track | Owns |
32 |---|---|---|
33 | `01-product-industry-research-design/` | Product & Industry Research (incl. Design) | Market/industry framing (IAM, passwordless auth, IDP aggregation space), personas and use cases, API and data contract design rationale, UX of the surfaces that would sit on top of this |
34 | `02-ai-security-architecture/` | AI Architecture & Security Architecture | The AI tooling/workflow used to produce this submission, threat modeling, auth/authz design for the API, credential and PII handling, secrets management, third-party connector security |
35 | `03-engineering-delivery/` | Engineering & Delivery | The actual Go code for all three questions, project structure, test strategy, delivery documentation |
36 | `04-infra-devops/` | Infra & DevOps | How this would be built, containerized, deployed, and operated — CI/CD, environments, multi-database configuration, observability |
37 | `05-data-ops/` | Data Ops | Data modeling for `user_profile` / `user_credential`, multi-database abstraction strategy, migrations, PII data governance and retention |
38
39 Directories are numbered for reading order in the final submission, not for sequencing of work — tracks can be worked in any order or in parallel.
40
41 ## Working principles for every track
42
43 - State your reasoning, not just your conclusion. LoginID is grading the "why" as much as the "what."
44 - Name the AI tool/workflow you used for that track's output (this project is being built with Claude Code; say so, and say how — e.g., subagents scoped per track, a given skill, a given model).
45 - Keep each track's CLAUDE.md self-contained enough that a fresh agent with no other context can pick it up and do correct work from it alone, plus this root file.
46 - Cross-reference other tracks by relative path (e.g., `../05-data-ops/CLAUDE.md`) rather than restating their content.
47 - Implementation is gated per story, not project-wide: Warren gave the implementation go on 2026-09-12 (evening) as an agile loop. Code (`.go`, `.sql`, `go.mod`, Dockerfiles, CI config) may be written only for a story the Product Owner has pulled, refined with the research team, and handed to engineering — recorded in `<track>/refinement/<LT-key>.md`. Work happens on a `feature/<LT-key>` branch, never directly on `main`. A story is done only when it is deployed to the public URL and the Product Owner has reviewed it there.
48 - **Attribution rule (Warren, 2026-09-12): every commit, PR, and Jira ticket says who did the work.** *Commits:* the git author is the persona doing the work — set once per worktree with `git config --worktree user.name "<Persona Name> (<track> <role>, <model>)"` and `git config --worktree user.email "<slug>@agents.loginid-takehome.invalid"` (`extensions.worktreeConfig` is enabled repo-wide) — and every commit message ends with trailers: `Story: LT-nn`, `Work-By: <persona> (<seat>); <persona> (<seat>)…` naming each hire who contributed and what they did (implementation, tests, review, objection seat), then `Co-Authored-By: Claude <model> <noreply@anthropic.com>`. *PRs:* title `LT-nn: <summary>`; body has a **Who did the work** section (lead, hires and seats, reviewers incl. 02's independent reviewer where applicable, models), plus links to the refinement doc and the Jira issue. *Jira:* every transition and every PR link is a comment beginning `Work-By: …` with the same names; the story's final comment before Done names who deployed and who reviewed. The PM applies the same rule to its own commits. Persona names, not "Claude" or "the team".
49 - **Shared working tree rule.** Every lead session shares this one checkout, so `git checkout`/`git switch` here changes everyone's tree — **never run them in this directory.** Feature branches live in worktrees outside the repo: `git worktree add /Users/warrenthompson/Source/LoginID-worktrees/<LT-key> -b feature/<LT-key> main`; work, commit, and push from that directory; the root tree stays on `main`. Remote: `origin` = `«email»:wtvamp/loginid-takehome.git` (public). Go code lives at the repo root (`cmd/`, `internal/`, `go.mod`); track directories hold documents.
50
51 ## Org, hiring, and orchestration
52
53 The project is run as an org: this PM agent (Dana Whitfield) orchestrates five standing track-lead agents, and each lead hires a small sub-team of specialist agents with deliberately varied personalities. **Each track lead runs as its own top-level Claude Code session**, launched from its track directory in its own tmux window — so it loads its own track `CLAUDE.md` and persona natively, owns and spawns its hires as named teammates in that window, and talks to the PM session through cross-session messaging. Hires belong to their lead's session, not the PM's. The governing files, all at the root:
54
55 - `PLAN.md` — the approved org plan for the planning phase (phases, gates, verification). `~/.claude/plans/` is scratch; this is the record.
56 - `PLANNING.md` — the thin cross-track board: one row per track, one writer per row, plus the service-boundaries section 03 owns and 04 reads.
57 - `CASTING.md` — archetype palette, org-wide variance rules, hire sizing, the persona-writing standard, the orchestration-pattern catalog (adversarial pair, red/blue, three hats, rotating devil's advocate, structured written debate, newcomer's question), the deep-research rule, the hire-creation procedure, and the registry of every hire.
58 - `LOG.md` — dated, append-only journal of what happened and why (team-lead writes it; it is part of the submission).
59 - `.claude/agents/` — one agent definition per hire (model pinned; a once-only per-agent `PreToolUse` hook displays its own persona on its first tool call). `.claude/AGENT_TEMPLATE.md` is the starting point (kept outside the agents directory so it is not registered as an agent type itself).
60 - `02-ai-security-architecture/planning-approach.md` — the agreed Claude-architecture approach: model/effort by role, coordination rules, hand-off standard, dependency order.
61
62 Rules that hold across every track:
63 - Every agent — lead or hire — has a profile-gen persona (name, temperament, professional history, technical background, stated blind spot). Hires are created text-first with `--output file`; portraits arrive later from `scripts/portrait-queue.sh`. Hires are **never** added as `profile-gen` markers in any `CLAUDE.md` — only the six markers that exist today.
64 - Hires do not spawn subagents. Only track leads (and team-lead) authorize or run deep research (patents, whitepapers, standards, primary sources); hires request it from their lead.
65 - Tracks never modify root-level state (this file, the root persona, root config); flag it to team-lead instead.
66 - **Decisions already made are not re-asked.** Warren's rulings reach tracks through the PM and are recorded in `LOG.md` and this file. A lead must not raise an `AskUserQuestion` to Warren for a decision that is already on the record (e.g., the deployment target, the public repo, the code go) — it asks the PM by message, which answers from the record or escalates. A dialog left open in a lead's session blocks that lead and everything queued behind it (this cost DevOps an hour on 2026-09-12). Genuinely new decisions that only Warren can make still go to Warren — through the PM.
67 - Hand-offs are files by relative path, written so the receiver can act without re-deriving the sender's reasoning; chat messages signal readiness, they do not carry content.
68 - **Before declaring anything handed off, merged-ready, or done, run `git status` and `git diff origin/main -- <your track>/`.** Four times in this project a finished artifact sat unpushed or on a dead branch while its author reported it complete (2026-09-12/13). The author is the worst-placed person to notice, because they can see the work; the reviewer looking at `origin/main` sees the truth. Also check `gh pr view <n> --json state` before pushing to a story branch — once the PM merges, that branch is dead and pushes to it go nowhere.
69 - Edit `CLAUDE.md` files at turn boundaries only (prompt-cache stability). Milestone `git` commits at the end of each phase; no mid-task commits.
70
71 ## Status
72
73 Planning (Phases 0–4) closed 2026-09-12: five track plans, 18 hires, three cross-track consistency passes (`decisions/cross-track-consistency.md`), and a Jira backlog (`LT-1`…`LT-52`). Implementation began the same evening as an agile loop (`LOG.md` from "Implementation go" onward). As of 2026-09-13 06:00 PDT: **Done end to end** (deployed to the public URL and PO-reviewed) — LT-34 walking skeleton, LT-32 issuer split-Deployment, LT-52 in-namespace Postgres + migrations. **Live:** `https://loginid-takehome.uplifttech.org/healthz` (build SHA == `main`, `db: ok`); `/profiles/search` and `/profiles/{id}` deployed behind RS256 JWT verification and failing closed; `/auth/token` a 501 placeholder until LT-51. **In Test** awaiting one joint live review with a real token: LT-36/37 (Postgres/CockroachDB and SQLite DAOs), LT-38 (conformance suite), LT-39 (REST search/read), LT-40 (auth middleware). **In dev:** LT-51 (client-credentials issuance; PRs #34 + #36), LT-41 (idp-connector, Q3). PRs #1–#37 merged. Teardown runs only on Warren's instruction (`04-infra-devops/teardown.md`).
74
75 <!-- profile-gen:start slug=dana-whitfield -->
76 @profiles/dana-whitfield/dana-whitfield.md
77 <!-- profile-gen:end slug=dana-whitfield -->
78 {
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/CLAUDE.md"
}1 # Track: Engineering & Delivery 2 3 Refer back to `../CLAUDE.md` for the full assignment text and how this track fits with the other four. 4 5 ## Mission 6 7 Write the actual code that answers all three assignment questions, in Go, implementing the designs the other tracks produce rather than re-deciding them here. Code does not need to be complete or working end-to-end — LoginID cares about the design and reasoning — but it should be structured and readable enough to demonstrate real engineering judgment. 8 9 ## Owns 10 11 - Question 1: the DAO layer for `user_profile` (name, address, phone) and `user_credential` (username, method, password), written to support multiple backing databases (PostgreSQL/CockroachDB, SQLite, etc.) behind a common interface, per the strategy in `../05-data-ops/CLAUDE.md`. 12 - Question 2: the RESTful API service to search/retrieve `user_profile` data, wired to the authentication mechanism designed in `../02-ai-security-architecture/CLAUDE.md`. 13 - Question 3: the third-party IDP connector service implementing `/auth` and `/identity` against the contracts specified in the assignment, following the connector security design in `../02-ai-security-architecture/CLAUDE.md`. 14 - Project structure, module layout, and inline documentation of design decisions and trade-offs made during implementation. 15 - Test strategy (unit/integration scope, what's stubbed vs. real) — tests don't need full coverage, but the approach should be stated. 16 17 ## Does not own 18 19 - Deciding the API auth mechanism or threat model — that's `../02-ai-security-architecture/CLAUDE.md`; implement what it specifies. 20 - Deciding the multi-database abstraction strategy or schema — that's `../05-data-ops/CLAUDE.md`; implement what it specifies. 21 - Deployment, containerization, CI/CD — that's `../04-infra-devops/CLAUDE.md`. 22 - Product/industry rationale for why the API is shaped this way — that's `../01-product-industry-research-design/CLAUDE.md`; reference it, don't re-derive it. 23 24 ## Deliverables 25 26 - Go source implementing questions 1, 2, and 3. 27 - A short README in this directory explaining what's implemented, what's stubbed/mocked, and why, plus which AI tool/workflow was used to produce the code and how. 28 - Any test files demonstrating the stated test strategy. 29 30 ## AI tooling note 31 32 Record which AI tool/workflow generated this track's code (this project is being built with Claude Code) and how much was AI-authored vs. human-reviewed/edited. 33 34 ## Status 35 36 Planning complete (2026-09-12): 4 hires spawned and live, `PLAN.md` approved, Service boundaries published in root `PLANNING.md`, four implementation-call decisions ruled (`decisions/go-layout-debate.md`, `error-semantics.md`, `context-propagation.md`, `test-double-strategy.md`), 05's DAO contract accepted (including a second-pass review and 05's resulting `CreateProfileWithCredential` fix), 02's `handoff-03-auth.md` v3 accepted, `backlog.md` written (11 stories, Epic `LT-3`). No `.go`/`.sql`/`go.mod` exists yet — still waiting on Warren's explicit implementation go-ahead. 37 38 <!-- profile-gen:start slug=renata-cole --> 39 @profiles/renata-cole/renata-cole.md 40 <!-- profile-gen:end slug=renata-cole --> 41
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/PLAN.md"
}1 # Track 03 (Engineering & Delivery) — Phase 1 Plan 2 3 ## Context 4 5 Team-lead has asked every track lead to plan in plan mode before any hiring or code. This 6 track (Renata Cole) owns the actual Go implementation of all three assignment questions — 7 the DAO, the REST API, and the IDP connector — so its plan needs to name concrete, 8 Jira-shaped deliverables, propose a 4-hire roster with real personality variance (per 9 `CASTING.md`), decide which decisions in this track deserve a debate pattern versus which are 10 waste, state the dependency order this track already committed to in 11 `../02-ai-security-architecture/planning-approach.md`, and produce the "Service boundaries" 12 section of root `PLANNING.md` that 04 (Theo) is blocked on reading. Nothing here is new 13 strategy — it operationalizes what's already agreed in `planning-approach.md` and the root 14 `CLAUDE.md`'s org section. No code is written in this phase. 15 16 ## 1. Deliverables & tasks (one Jira Story each; Epic = this track) 17 18 *This table is the Phase 1 planning snapshot (2026-09-12). `backlog.md` (11 stories, Jira Epic `LT-3`) is the current, authoritative, PR-sized expansion — its story descriptions and acceptance criteria supersede this table's "What"/"Acceptance standard" columns wherever they differ. Consult `backlog.md`, not this table, for current story shape, hand-off versions, or acceptance criteria.* 19 20 | Story | What | Hand-off consumed | Hand-off produced | Acceptance standard | 21 |---|---|---|---|---| 22 | S1. Scaffolding | Go module layout, `cmd/` entrypoints, route wiring, stub handlers, config loading | none (assignment text only) | `PLANNING.md` Service boundaries (this plan, §4 below) | Buildable skeleton; 04 can read the boundaries doc without asking a follow-up question | 23 | S2. DAO per backend | `internal/dao` `Repository` interface + Postgres/CockroachDB/SQLite implementations for `user_profile`, `user_credential` | 05's Go-shaped contract in `../05-data-ops/multi-db-strategy.md` (interface signatures, field types/nullability, credential-method-as-lookup-table note) | none outbound | 03 implements directly from the contract with no re-derivation of schema or interface decisions; if the contract is prose instead of Go signatures, this story is blocked and kicked back to 05 | 24 | S3. REST handlers + auth middleware | Search/retrieve `user_profile` endpoints (Q2) + middleware enforcing 02's scheme | 02's receiver-shaped auth hand-off (token scheme, validation point — the deferred follow-up to `api-auth-design.md` noted in 02's own `CLAUDE.md` Status) | none outbound | Middleware implements the scheme as specified; no alternatives re-litigated in code | 25 | S4. Connector `/auth` + `/identity` | Go client + handlers per the assignment's fixed JSON contracts | 02's `connector-security.md` (token lifecycle: no-cache-by-default, fetch per `/identity` call, discard on return — TTL/refresh apply only to how quickly a re-fetch happens, not to any stored token) | none outbound | Token-lifecycle logic matches 02's spec exactly; endpoint paths/bodies are the assignment's, not re-designed | 26 | S5. Tests | Table-driven unit tests for DAO (mocked driver), handlers (mocked DAO), connector (mocked HTTP), **plus the cross-backend conformance suite 05's per-driver ruling is conditional on (`multi-db-strategy.md` §7) — one suite, written once against the `Repository` interface, run against every backend** (consistency-pass finding F25: this was a requirement with no named deliverable until now; `backlog.md`'s S5 is the current, authoritative expansion of this story) | S1–S4 outputs | none | Stated strategy: unit-heavy, integration deliberately stubbed (assignment doesn't require full end-to-end); documented, not silently absent | 27 | S6. README | What's implemented vs. stubbed/mocked and why; AI tool/workflow used (this plan's own hiring + patterns); test-strategy section must state the hand-written-fake rule from `decisions/test-double-strategy.md` (same-PR fake update on interface change) since that's where LoginID reads the test approach | all of the above | the track's README | A cold reader can tell what runs vs. what's a stub without asking | 28 29 ## 2. Hire roster — 4 hires (suggested cast from `CASTING.md`, adopted as-is) 30 31 All four: **model sonnet** (planning/design/implementation tier per `planning-approach.md`; 32 opus is reserved for the connector token-lifecycle *implementation* seat in a later phase, not 33 planning — none of these four need it now). No hire spawns subagents; no CLAUDE.md marker; 34 profile-gen text-first, after "hiring go". 35 36 **Oren Castellan — Detail Hawk, code reviewer.** *Function:* second-pass review on DAO/handler/connector Go for error semantics, nullability, off-by-one — the review-seat Renata asked for on her own output, plus the independent pass 02 called for on security-graded surfaces. *Tags:* serious, cautious. *Pattern/role:* rotating devil's advocate seat (implementation calls); second-pass reviewer on S3/S4. 37 38 *Persona:* Meticulous and understated; in review he goes quiet, then produces a numbered list nobody else caught. Decides by tracing every input to its type and zero value before trusting a code path. Pushes back on "the happy path handles it" and on errors swallowed with `_`. Eight years reviewing Go services at a logistics company, two more doing contract review for a fintech's PCI-scoped repos. Deep in `go vet`/`staticcheck`, table-driven tests, and nil-vs-empty-slice distinctions. Blind spot: he can spend an hour on a rare nullability edge case while missing that the function solves the wrong problem — pair him with someone who checks the solution's shape first. In disagreement he stays procedural, names the specific line and failure, and drops it once satisfied. (129 words) 39 40 **Nolan Reyes — Veteran, ops/migrations/token-lifecycle sanity.** *Function:* pattern-matches implementation and layout decisions against real production failure modes; pessimist hat in the Go-layout three-hats run. *Tags:* serious, cautious. *Pattern/role:* three-hats pessimist (Go layout); rotating devil's advocate participant. 41 42 *Persona:* Dry and unhurried; opens reviews with what broke last time he saw this pattern in production. Decides from incident history more than documentation. Pushes back on any "this will just work" claim about retries, timeouts, or token refresh under load. Eleven years in platform ops — six at a regional bank's card-issuing platform, five running SRE for a logistics SaaS — paged once for an expired-token cascade and once for a migration that locked a table for forty minutes; never forgot either. Deep in Go's `context` package, connection pooling, and graceful shutdown. Blind spot: he over-indexes on failures he personally lived through and under-weights new ones. Argues by scenario, not principle, and concedes fast when shown a failure mode he hasn't seen. (134 words) 43 44 **Marisol Ferran — Enthusiast, Renata's temperament opposite (required).** *Function:* pushes for trying the promising-but-unproven tool (codegen, generics) rather than the safe default; optimist hat in three-hats. *Tags:* playful, bold. *Pattern/role:* three-hats optimist (Go layout); counterweight voice on S2's driver-abstraction choice. 45 46 *Persona:* Bright and quick to say "let's just try it" before the room finishes weighing options; energized by tools that remove boilerplate. Decides by prototyping fast and showing the result rather than arguing it abstractly. Pushes back on "we've always done it this way" and on process that delays the first line of code by a week. Four years building internal tools at a logistics startup, two more at a small dev-tools shop evaluating codegen and ORM options for clients. Comfortable with `sqlc`, Go generics, and OpenAPI-driven scaffolding. Blind spot: she underweights the years-long operational cost of a clever tool and can talk a team into a dependency nobody wants to own. In disagreement she stays upbeat, offers to build the smaller version on the spot, and yields once the maintenance cost is spelled out. (141 words) 47 48 **Ines Dabrowski — Systems Cartographer, Go layout / service topology.** *Function:* owns the diagramming behind the Service boundaries decision below; draws the package/dependency map before anyone commits to prose. *Tags:* serious, bold. *Pattern/role:* drafts the Service boundaries doc (§4); non-hat contributor to three-hats as tie-break if optimist/pessimist stall. 49 50 *Persona:* Calm and visual; sketches a box-and-arrow diagram before committing an opinion to words. Decides by mapping every boundary and dependency first, then asking what crosses it and why. Pushes back on two services sharing a package for convenience rather than a real shared concern. Seven years as a platform engineer at a multi-tenant SaaS company, three designing service boundaries for a payments integration team. Strong on Go module layout, dependency direction, and where an interface should live versus where it's implemented. Blind spot: she can over-abstract a boundary that would have been fine as one package at this project's size, mistaking cleanliness for necessity. In disagreement she draws the alternative rather than argues it, and updates the diagram in front of the room when persuaded. (135 words) 51 52 Roster spans both required axes (serious/playful via Marisol; cautious/bold via Ines and 53 Marisol), includes Renata's required opposite (Marisol), and duplicates no archetype from 54 another track's *function* (Detail Hawk here is a code reviewer; 05's Detail Hawk is a DDL 55 reviewer, a different function — allowed per variance rule 2). 56 57 ## 3. Debate plan 58 59 - **Three hats — Go layout (one binary vs. two).** Marisol (optimist: ship one binary, less 60 ceremony) / Nolan (pessimist: two binaries, because a shared-everything binary is how a 61 card-issuing platform he worked on ended up with the connector's outbound vendor calls 62 taking down the client-facing API during an incident) / **Renata takes the pragmatist hat 63 herself** rather than hiring a fifth (catalog explicitly allows this) — she's the 64 Minimalist-leaning lead per `CASTING.md` §7. Ines drafts the resulting diagram either way. 65 Decision recorded in `decisions/go-layout-debate.md`. 66 - **Rotating devil's advocate — implementation calls.** Error semantics (wrap vs. sentinel 67 errors), context propagation into the DAO layer, test-double strategy (interface fakes vs. 68 generated mocks). Seat rotates Oren → Nolan → Marisol → Ines per decision so no one hire 69 becomes "the objector." Cheapest pattern; run on every non-trivial implementation call in 70 S2–S4. 71 72 *(Consistency-pass finding F47: this section named "Callum" as the pessimist/rotation-2 hire 73 through Phase 1 while the actual roster in §2 above was already "Nolan Reyes" — Callum 74 Reyes was renamed to clear a first-name collision with 04's Callum Ferreira before hiring; 75 this section simply hadn't been updated to match. Fixed above; no roster change.)* 76 - **Newcomer's-question pass on hand-offs — run by Renata herself, not a hired Newcomer.** 77 This track wasn't allocated a Newcomer seat (04 has one; hiring a second here would exceed 78 the intent of a 4-hire cap for marginal gain). Renata reads 05's `multi-db-strategy.md` and 79 02's receiver-shaped hand-offs cold, the moment they land, and lists anything she can't act 80 on without re-deriving the sender's reasoning — same test the pattern exists to run, just 81 performed by the actual receiver instead of a proxy. 82 - **Explicit waste:** the `/auth` and `/identity` endpoint paths and JSON bodies are fixed by 83 the assignment text — never debated. Red team/blue team is 02's pattern for the token 84 lifecycle's security properties, not this track's to re-run; 03 only debates *how* it 85 implements what 02 specifies (error handling, retries), never *whether* the scheme is right. 86 87 ## 4. Dependencies (and the Service boundaries output for 04) 88 89 **Split start**, consistent with `planning-approach.md` §2: S1 (scaffolding) starts as soon as 90 hiring completes — no upstream blocker. S2 (DAO) is blocked on 05's Go-shaped contract landing 91 *stable* (not polished — unlikely to be rewritten). S3's middleware is blocked on 02's 92 receiver-shaped auth hand-off; S3's route wiring and non-auth handler logic can start earlier. 93 S4 is blocked on 02's `connector-security.md` token-lifecycle spec, which already exists in 94 prose form — implementable now, refined if 02's deferred receiver-shaped version changes 95 anything. 96 97 **Service boundaries (written into root `PLANNING.md` alongside this plan):** 98 99 *This subsection is the Phase 1 snapshot (2026-09-12, before hiring). Root `PLANNING.md`'s 100 Service boundaries section is the current, authoritative version — it has since gained 101 `internal/app` (shared bootstrap, `decisions/go-layout-debate.md`), `DB_DSN_FILE` (02's 102 `handoff-04-secrets.md` S7), literal `AUTH_JWT_ISSUER`/`AUTH_JWT_AUDIENCE` values, and the 103 connector's own audience/client-credential variables (consistency-pass F15). Consult 104 `PLANNING.md`, not this snapshot, for what 04 or anyone else should build against.* 105 106 - **Two binaries**, not one: `cmd/api-service` (Q1 DAO + Q2 REST API, client-facing, bearer- 107 token auth) and `cmd/idp-connector` (Q3, outbound-only to vendors). Reasoning: different 108 threat models and auth surfaces per 02's track, independent scaling/deploy needs, and it 109 mirrors the assignment's own Q2/Q3 split rather than inventing a new boundary. 110 - **Package layout:** 111 ``` 112 cmd/api-service/main.go 113 cmd/idp-connector/main.go 114 internal/dao/ # Repository interface + postgres/cockroachdb/sqlite implementations 115 internal/model/ # shared structs: UserProfile, UserCredential 116 internal/api/ # REST handlers, router, auth middleware 117 internal/connector/ # /auth, /identity client + handlers 118 internal/config/ # env-var config loading, shared by both binaries 119 ``` 120 - **Config surface:** environment variables only (12-factor, container-friendly for 04) — 121 `DB_DRIVER` (postgres|cockroachdb|sqlite), `DB_DSN`, `HTTP_ADDR`, `AUTH_JWT_ISSUER` / 122 `AUTH_JWT_AUDIENCE` (names pending 02's final scheme), `IDP_ABC_BASE_URL`, 123 `IDP_ABC_CLIENT_ID` / `IDP_ABC_CLIENT_SECRET`. No flags, no config files. 124 - **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)` 125 chosen at startup from `DB_DRIVER`; every backend satisfies one `Repository` interface 126 (exact method signatures finalized once 05's contract lands — the factory *shape* is stable 127 now, its return type's method set is not). 128 - **What 04 may treat as stable:** the two-binary split, the `cmd/` entrypoint names, the 129 env-var config approach and the variable names above, and the `dao.New(driver, dsn)` factory 130 signature. **Not yet stable:** internal DAO implementation details, ORM/query-builder choice, 131 and handler-package internals — those can still shift as 05's and 02's hand-offs land. 132 133 ## Amendment: QA function (2026-09-12, Warren's agile-loop go-ahead) 134 135 **Oren Castellan leads QA** (unit, integration, and the end-to-end/UX acceptance tests written from the PO's review script); **Nolan Reyes supports**. 02's independent second review still applies on top of this for every `security-graded` story — QA and 02's review are not the same gate. 136 137 ## Verification 138 139 - `PLAN.md` for this track matches this file verbatim once copied. 140 - `PLANNING.md` row 03 updated to "plan drafted — awaiting hiring go"; Service boundaries 141 section replaces its "TBD" with the content in §4 above. 142 - No `.go`, `.sql`, `go.mod`, or agent-definition files exist yet — only prose changed. 143 - Roster (4 hires) checked against `CASTING.md` variance rules before messaging team-lead: 144 no archetype repeated within this team; Renata's required opposite (Enthusiast) present; 145 two temperament axes spanned; no first name collides with any lead. 146
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/profiles/oren-castellan/oren-castellan.md"
}1 --- 2 schema_version: 1 3 name: "Oren Castellan" 4 slug: "oren-castellan" 5 image: "profiles/oren-castellan/oren-castellan.png" 6 7 nsfw: false 8 display: 9 image: true 10 name: true 11 autostart: false 12 generation: 13 backend: "comfyui" 14 model: "comfyui-workflow.json" 15 prompt: "Oren Castellan, a meticulous and understated man in his late 30s with short neat hair, wire-rim glasses, wearing a plain navy button-down shirt, calm focused expression, head-and-shoulders portrait, centered, neutral light gray studio background, professional corporate headshot photography, soft studio lighting, sharp focus, photorealistic" 16 negative_prompt: blurry, low quality, distorted, extra limbs, watermark, text, nsfw, nude, nudity, explicit, sexual content 17 seed: null 18 gif_mode: null 19 created_at: "2026-09-12T00:00:00Z" 20 --- 21 22 # Oren Castellan 23 24  25 26 27 ## Personality 28 29 Meticulous and understated; in review he goes quiet, then produces a numbered list nobody else caught. Decides by tracing every input to its type and zero value before trusting a code path. Pushes back on "the happy path handles it" and on errors swallowed with `_`. Eight years reviewing Go services at a logistics company, two more doing contract review for a fintech's PCI-scoped repos. Deep in `go vet`/`staticcheck`, table-driven tests, and nil-vs-empty-slice distinctions. Blind spot: he can spend an hour on a rare nullability edge case while missing that the function solves the wrong problem — pair him with someone who checks the solution's shape first. In disagreement he stays procedural, names the specific line and failure, and drops it once satisfied. 30 31
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && git status && git log --oneline -5 && git fetch origin >/dev/null 2>&1 && git log origin/main --oneline -3"
}On branch feature/LT-41 Your branch is up to date with 'origin/feature/LT-41'. nothing to commit, working tree clean 5d67789 LT-41: fix lint findings + add missing per-identity rate-limit dimension dd433e0 LT-41: idp-connector /auth + /identity against a stubbed ABC/XYZ vendor 2ea4b5d Merge PR #34: LT-51 OAuth2 client-credentials issuance — Postgres client store, JWKS endpoint, GrantLimiter b7db08e LT-51: OAuth2 client-credentials grant, Postgres-backed client store 00f4194 Root CLAUDE.md Status: implementation loop state as of 2026-09-13 06:00 (was stale: 'no code until go-ahead') 4313883 Merge PR #39: LT-51 — PHC-encoded client secrets with verify-then-rehash; goose StatementBegin/End on the issuer migration; integration test runs real goose.Up 2bf952c chore: go mod tidy (goose direct dep); doc notes from Nolan's review 483c0f1 test: rehash-persist failure must not fail an already-earned grant
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && go build ./... && echo BUILD_OK && go vet ./... && echo VET_OK"
}BUILD_OK VET_OK
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && go test ./... -race 2>&1 | tail -50"
}? loginid-takehome/cmd/api-service [no test files] ? loginid-takehome/cmd/idp-connector [no test files] ok loginid-takehome/internal/api (cached) ok loginid-takehome/internal/app (cached) ok loginid-takehome/internal/config (cached) ok loginid-takehome/internal/connector (cached) ok loginid-takehome/internal/dao (cached) ok loginid-takehome/internal/dao/conformance (cached) ok loginid-takehome/internal/dao/postgres (cached) ok loginid-takehome/internal/dao/sqlite (cached) ? loginid-takehome/internal/model [no test files]
{
"command": "which staticcheck || ls $(go env GOPATH)/bin/staticcheck 2>/dev/null || echo NOTFOUND"
}staticcheck not found /Users/warrenthompson/go/bin/staticcheck
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && /Users/warrenthompson/go/bin/staticcheck ./... 2>&1"
}(Bash completed with no output)
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && cat internal/connector/identitylimiter.go"
}package connector
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"sync"
"time"
"loginid-takehome/internal/api"
)
// identityLimiterBaseBackoff/MaxBackoff/AlertThreshold are this
// component's own engineering defaults, same reasoning as
// connectorVendorLimiterBaseBackoff/etc. in internal/app/connector_router.go
// — flagged for 02's review rather than treated as ruled numbers.
const (
identityLimiterBaseBackoff = 1 * time.Second
identityLimiterMaxBackoff = 5 * time.Minute
identityLimiterAlertThreshold = 10
)
// maxTrackedIdentities bounds this limiter's own tracked-key set
// independently of api.GrantLimiter's time-based sweep — the same
// "bounded distinct-key cardinality" pattern LT-40's JWKSCache already
// uses for unknown kids (jwksMaxTrackedUnknownKids), extended here to a
// much larger value: unlike a kid (a small, bounded set of currently
// valid signing keys), a legitimate identity space can genuinely be
// large (many real vendor end-users), so capping at JWKS's own
// small number would fail open on ordinary traffic almost immediately.
// This is this component's own engineering default (flagged for 02's
// review), not a ruled number.
const maxTrackedIdentities = 10000
// identityTrackedKeyTTL/identitySweepInterval bound how long a tracked
// identity key survives with no further activity — same sweep-based
// eviction shape as InProcessGrantLimiter/InProcessTouchCounter/
// JWKSCache's own unknown-kid map, so a burst of one-off garbage
// identities doesn't permanently consume tracked-key slots.
const (
identityTrackedKeyTTL = time.Hour
identitySweepInterval = time.Minute
)
// identityHMACKey is generated once per process — this limiter's keys
// never need to survive a restart (the state itself is in-process and
// ephemeral), so a random key generated at process start is sufficient
// and avoids embedding any fixed secret in source.
var identityHMACKey = mustRandomBytes(32)
func mustRandomBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("connector: generating identity-limiter HMAC key: " + err.Error())
}
return b
}
// hashIdentity derives this limiter's map key from (callerSub,
// attemptedIdentity) — HMAC-SHA256, never the raw attempted identity
// (a vendor username, or a phone/name pair) held as a map key or ever
// logged (Tomasz Wrede's review, PR #40: "a salted/hashed submitted
// identity, never the raw username/phone/whatever's being tried against
// the vendor"). Composite over BOTH callerSub and attemptedIdentity —
// per Marcus Ilori's ruling — so two different internal callers
// submitting the same identity string never share one bucket, even
// though this project's actual topology has only one internal caller
// today.
func hashIdentity(callerSub, attemptedIdentity string) string {
mac := hmac.New(sha256.New, identityHMACKey)
mac.Write([]byte(callerSub))
mac.Write([]byte{0}) // separator: prevents ("ab","c") colliding with ("a","bc")
mac.Write([]byte(attemptedIdentity))
return hex.EncodeToString(mac.Sum(nil))
}
// identityRateLimiter is attack-tree leaf 5's missing second dimension
// (Tomasz Wrede's cold review of PR #40): connector-security.md's own
// ruled control specifies backoff "per identity attempted, not just per
// client" — the caller-level GrantLimiter instance
// (internal/app/connector_router.go) alone means a single success
// anywhere resets that caller's ENTIRE consecutive-failure count,
// letting a compromised caller cycle through many stolen identities
// indefinitely as long as it occasionally succeeds against one of them.
// This type tracks backoff per (caller, attempted identity) instead, so
// retrying the SAME identity trips independently of whether some OTHER
// identity under the same caller just succeeded — and, symmetrically,
// one legitimate user's typo never affects a different legitimate
// user's own attempt under the same caller (both are named,
// enforcement-tested properties, not just a design intent).
//
// Wraps api.GrantLimiter (not a new backoff mechanism — the hashed
// identity is used as BOTH of GrantLimiter's own two key slots, since
// neither "client_id" nor "source IP" is the right name for what this
// tracks; using the same value for both is harmless, just double
// bookkeeping for one entity) with an explicit, bounded tracked-key set
// so an attacker submitting unbounded distinct garbage identities can't
// grow memory without limit between GrantLimiter's own periodic sweeps.
type identityRateLimiter struct {
underlying *api.InProcessGrantLimiter
mu sync.Mutex
trackedAt map[string]time.Time
lastSweep time.Time
}
func NewIdentityRateLimiter() *identityRateLimiter {
return &identityRateLimiter{
underlying: api.NewInProcessGrantLimiter(
identityLimiterBaseBackoff, identityLimiterMaxBackoff, identityLimiterAlertThreshold, nil,
),
trackedAt: make(map[string]time.Time),
}
}
// allow reports whether an attempt against attemptedIdentity by
// callerSub may proceed. Once maxTrackedIdentities distinct keys are
// already tracked, a genuinely NEW identity is allowed through this
// limiter (fails open on THIS dimension only) rather than blocking
// every new identity outright — the caller-level GrantLimiter still
// provides a base rate limit regardless, so this degrades to
// caller-level-only protection under a tracked-key flood rather than
// denying service to legitimate new identities (same reasoning as
// JWKSCache's own bounded unknown-kid cap).
func (l *identityRateLimiter) allow(ctx context.Context, callerSub, attemptedIdentity string) (bool, error) {
key := hashIdentity(callerSub, attemptedIdentity)
l.mu.Lock()
now := time.Now()
l.sweepLocked(now)
if _, tracked := l.trackedAt[key]; !tracked {
if len(l.trackedAt) >= maxTrackedIdentities {
l.mu.Unlock()
return true, nil
}
l.trackedAt[key] = now
} else {
l.trackedAt[key] = now
}
l.mu.Unlock()
return l.underlying.Allow(ctx, key, key)
}
func (l *identityRateLimiter) recordFailure(ctx context.Context, callerSub, attemptedIdentity string) {
key := hashIdentity(callerSub, attemptedIdentity)
l.underlying.RecordFailure(ctx, key, key)
}
func (l *identityRateLimiter) recordSuccess(ctx context.Context, callerSub, attemptedIdentity string) {
key := hashIdentity(callerSub, attemptedIdentity)
l.underlying.RecordSuccess(ctx, key, key)
}
func (l *identityRateLimiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < identitySweepInterval {
return
}
l.lastSweep = now
for k, seenAt := range l.trackedAt {
if now.Sub(seenAt) >= identityTrackedKeyTTL {
delete(l.trackedAt, k)
}
}
}{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -rn \"func.*InProcessGrantLimiter.*Allow\\|func.*InProcessGrantLimiter.*RecordFailure\\|func.*InProcessGrantLimiter.*RecordSuccess\\|isBlocked\" internal/api/*.go"
}internal/api/grantlimiter.go:110:func (l *InProcessGrantLimiter) Allow(_ context.Context, clientID, sourceIP string) (bool, error) {
internal/api/grantlimiter.go:115: return !isBlocked(l.byClientID[clientID], now) && !isBlocked(l.bySourceIP[sourceIP], now), nil
internal/api/grantlimiter.go:139:func isBlocked(s *grantBackoffState, now time.Time) bool {
internal/api/grantlimiter.go:143:func (l *InProcessGrantLimiter) RecordFailure(_ context.Context, clientID, sourceIP string) {
internal/api/grantlimiter.go:177:func (l *InProcessGrantLimiter) RecordSuccess(_ context.Context, clientID, sourceIP string) {{
"file_path": "/Users/warrenthompson/Source/LoginID-worktrees/LT-41/internal/api/grantlimiter.go"
}1 package api
2
3 import (
4 "context"
5 "sync"
6 "time"
7 )
8
9 // GrantLimiter enforces the token-issuing endpoint's own failed-grant
10 // rate limiting and progressive backoff — handoff-03-auth.md v3:
11 // "Token-endpoint brute-force protection, separate from the resource-API
12 // rate limits ... failed-grant rate limiting and progressive backoff per
13 // client_id and per source IP on the token-issuing endpoint itself, plus
14 // alerting on a sustained run of failed grants against one client_id."
15 //
16 // This is genuinely separate from RateLimiter (ratelimit.go): that type
17 // gates the resource API by an already-authenticated sub, at a fixed
18 // per-minute rate; this one gates the endpoint that issues the
19 // credential in the first place, keyed independently by client_id AND
20 // by source IP — a distributed brute force (many IPs, one client_id)
21 // and a single-source credential-stuffing run (one IP, many client_ids)
22 // are both attack shapes this is meant to slow down, and either key
23 // exceeding its own backoff blocks the attempt.
24 //
25 // Not wired to a live endpoint by this story: /auth/token's real grant
26 // logic is LT-51's scope (this story's non-goals). This is the
27 // ready-to-use component LT-51 constructs and calls from the real
28 // handler — building it now, tested in isolation, means LT-51 doesn't
29 // also have to design a backoff/alerting scheme under its own deadline.
30 type GrantLimiter interface {
31 // Allow reports whether a grant attempt for clientID from sourceIP
32 // may proceed right now — false while either key is in backoff.
33 Allow(ctx context.Context, clientID, sourceIP string) (bool, error)
34 // RecordFailure records a failed grant attempt for both clientID and
35 // sourceIP, advancing each key's backoff state independently.
36 // Crossing the alert threshold for a given client_id (not source IP
37 // — the requirement names client_id specifically) fires alert
38 // exactly once per crossing, not once per subsequent failure.
39 RecordFailure(ctx context.Context, clientID, sourceIP string)
40 // RecordSuccess clears backoff state for both clientID and
41 // sourceIP — a successful grant resets the count, standard
42 // exponential-backoff practice: this deters sustained brute force,
43 // it does not permanently degrade a legitimate client that mistyped
44 // a secret once.
45 RecordSuccess(ctx context.Context, clientID, sourceIP string)
46 }
47
48 // grantBackoffState is one key's (a client_id's, or a source IP's)
49 // consecutive-failure bookkeeping.
50 type grantBackoffState struct {
51 consecutiveFailures int
52 blockedUntil time.Time
53 alerted bool
54 }
55
56 // InProcessGrantLimiter is an in-process implementation. As with
57 // RateLimiter/TouchCounter's in-process implementations, a shared store
58 // is the multi-instance replacement behind the same interface — the
59 // mechanism is this track's call, per handoff-03-auth.md v3.
60 //
61 // Both maps are swept periodically (see sweepLocked) rather than left to
62 // grow forever: this component's own doc comment names "a distributed
63 // brute force (many IPs, one client_id)" as a defended attack shape, and
64 // that is exactly the traffic pattern that would otherwise grow
65 // bySourceIP without bound — every distinct attacking IP earns a
66 // permanent entry that only a matching RecordSuccess would remove, which
67 // an attacking IP by definition never triggers (Nolan Reyes, PR #30
68 // review — the same unbounded-growth shape an earlier PR's TouchCounter
69 // review already found and fixed once).
70 type InProcessGrantLimiter struct {
71 mu sync.Mutex
72 byClientID map[string]*grantBackoffState
73 bySourceIP map[string]*grantBackoffState
74 baseBackoff time.Duration
75 maxBackoff time.Duration
76 alertThreshold int
77 alertFn func(clientID string, consecutiveFailures int)
78 now func() time.Time
79 lastSweep time.Time
80 }
81
82 // grantSweepInterval and grantEntryTTL bound how long a key's state
83 // survives with no further failures against it. A blocked key is never
84 // swept before its own backoff has elapsed (sweeping mid-block would
85 // silently un-block it early); grantEntryTTL is generous past any
86 // realistic maxBackoff so a legitimately still-escalating attacker isn't
87 // reset early either.
88 const (
89 grantSweepInterval = time.Minute
90 grantEntryTTL = time.Hour
91 )
92
93 // NewInProcessGrantLimiter constructs a limiter with exponential backoff
94 // starting at baseBackoff (doubling per consecutive failure, capped at
95 // maxBackoff) and alertFn called once per client_id crossing
96 // alertThreshold consecutive failures. alertFn may be nil (no alerting —
97 // tests use this; production wiring passes a real sink).
98 func NewInProcessGrantLimiter(baseBackoff, maxBackoff time.Duration, alertThreshold int, alertFn func(clientID string, consecutiveFailures int)) *InProcessGrantLimiter {
99 return &InProcessGrantLimiter{
100 byClientID: make(map[string]*grantBackoffState),
101 bySourceIP: make(map[string]*grantBackoffState),
102 baseBackoff: baseBackoff,
103 maxBackoff: maxBackoff,
104 alertThreshold: alertThreshold,
105 alertFn: alertFn,
106 now: time.Now,
107 }
108 }
109
110 func (l *InProcessGrantLimiter) Allow(_ context.Context, clientID, sourceIP string) (bool, error) {
111 l.mu.Lock()
112 defer l.mu.Unlock()
113 now := l.now()
114 l.sweepLocked(now)
115 return !isBlocked(l.byClientID[clientID], now) && !isBlocked(l.bySourceIP[sourceIP], now), nil
116 }
117
118 // sweepLocked drops any entry whose blockedUntil is at least
119 // grantEntryTTL in the past — called with l.mu already held, at most
120 // once per grantSweepInterval, so it adds no per-call cost beyond a
121 // single time comparison on every other call.
122 func (l *InProcessGrantLimiter) sweepLocked(now time.Time) {
123 if now.Sub(l.lastSweep) < grantSweepInterval {
124 return
125 }
126 l.lastSweep = now
127 sweepMap(l.byClientID, now)
128 sweepMap(l.bySourceIP, now)
129 }
130
131 func sweepMap(m map[string]*grantBackoffState, now time.Time) {
132 for key, s := range m {
133 if now.Sub(s.blockedUntil) >= grantEntryTTL {
134 delete(m, key)
135 }
136 }
137 }
138
139 func isBlocked(s *grantBackoffState, now time.Time) bool {
140 return s != nil && now.Before(s.blockedUntil)
141 }
142
143 func (l *InProcessGrantLimiter) RecordFailure(_ context.Context, clientID, sourceIP string) {
144 l.mu.Lock()
145 defer l.mu.Unlock()
146 now := l.now()
147
148 cs := l.advance(l.byClientID, clientID, now)
149 l.advance(l.bySourceIP, sourceIP, now)
150
151 if cs.consecutiveFailures >= l.alertThreshold && !cs.alerted {
152 cs.alerted = true
153 if l.alertFn != nil {
154 l.alertFn(clientID, cs.consecutiveFailures)
155 }
156 }
157 }
158
159 // advance increments key's consecutive-failure count and sets its
160 // exponential backoff window, creating the state entry if this is its
161 // first failure.
162 func (l *InProcessGrantLimiter) advance(m map[string]*grantBackoffState, key string, now time.Time) *grantBackoffState {
163 s, ok := m[key]
164 if !ok {
165 s = &grantBackoffState{}
166 m[key] = s
167 }
168 s.consecutiveFailures++
169 backoff := l.baseBackoff << uint(s.consecutiveFailures-1) // 2^(n-1) * base
170 if backoff > l.maxBackoff || backoff <= 0 { // overflow guard: a left-shifted duration can wrap negative
171 backoff = l.maxBackoff
172 }
173 s.blockedUntil = now.Add(backoff)
174 return s
175 }
176
177 func (l *InProcessGrantLimiter) RecordSuccess(_ context.Context, clientID, sourceIP string) {
178 l.mu.Lock()
179 defer l.mu.Unlock()
180 delete(l.byClientID, clientID)
181 delete(l.bySourceIP, sourceIP)
182 }
183 {
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -n \"type Identity struct\" -A 12 internal/connector/*.go internal/model/*.go 2>/dev/null; echo ---; grep -n \"type identityResponseBody struct\" -A 12 internal/connector/*.go; echo ---; grep -n \"type vendorIdentityResponse struct\" -A 12 internal/connector/*.go"
}internal/connector/vendorclient.go:22:type Identity struct {
internal/connector/vendorclient.go-23- Name string
internal/connector/vendorclient.go-24- Phone string
internal/connector/vendorclient.go-25- StreetAddress string
internal/connector/vendorclient.go-26- Locality string
internal/connector/vendorclient.go-27- Region string
internal/connector/vendorclient.go-28- PostalCode string
internal/connector/vendorclient.go-29- Country string
internal/connector/vendorclient.go-30-}
internal/connector/vendorclient.go-31-
internal/connector/vendorclient.go-32-// VendorClient is this connector's seam to a third-party IDP. Both
internal/connector/vendorclient.go-33-// methods take the vendor bearer token as an explicit parameter, never a
internal/connector/vendorclient.go-34-// client-level field — connector-security.md §1's fetch-use-zeroize
---
internal/connector/handlers.go:214:type identityResponseBody struct {
internal/connector/handlers.go-215- Name string `json:"name"`
internal/connector/handlers.go-216- Phone string `json:"phone"`
internal/connector/handlers.go-217- StreetAddress string `json:"street_address"`
internal/connector/handlers.go-218- Locality string `json:"locality"`
internal/connector/handlers.go-219- Region string `json:"region"`
internal/connector/handlers.go-220- PostalCode string `json:"postal_code"`
internal/connector/handlers.go-221- Country string `json:"country"`
internal/connector/handlers.go-222-}
internal/connector/handlers.go-223-
internal/connector/handlers.go-224-// vendorAccessTokenHeader is the header /identity accepts the vendor's
internal/connector/handlers.go-225-// access_token on — a header, not a body field, since the assignment's
internal/connector/handlers.go-226-// fixed {"phone","name"} body has no credential field (LT-41 acceptance
---
internal/connector/httpvendorclient.go:130:type vendorIdentityResponse struct {
internal/connector/httpvendorclient.go-131- Name string `json:"name"`
internal/connector/httpvendorclient.go-132- Phone string `json:"phone"`
internal/connector/httpvendorclient.go-133- StreetAddress string `json:"street_address"`
internal/connector/httpvendorclient.go-134- Locality string `json:"locality"`
internal/connector/httpvendorclient.go-135- Region string `json:"region"`
internal/connector/httpvendorclient.go-136- PostalCode string `json:"postal_code"`
internal/connector/httpvendorclient.go-137- Country string `json:"country"`
internal/connector/httpvendorclient.go-138-}
internal/connector/httpvendorclient.go-139-
internal/connector/httpvendorclient.go-140-func (c *HTTPVendorClient) FetchIdentity(ctx context.Context, accessToken, phone, name string) (Identity, error) {
internal/connector/httpvendorclient.go-141- reqBody, err := json.Marshal(vendorIdentityRequest{Phone: phone, Name: name})
internal/connector/httpvendorclient.go-142- if err != nil {{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -n \"identityResponseBody(identity)\\|Identity(body)\" internal/connector/*.go"
}internal/connector/handlers.go:312: writeJSON(w, http.StatusOK, identityResponseBody(identity)) internal/connector/httpvendorclient.go:174: return Identity(body), nil
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -n \"BoundedCardinality\" -A 40 internal/connector/identitylimiter_test.go | head -60"
}123:func TestIdentityRateLimiter_BoundedCardinality_DegradesOpenNotClosed(t *testing.T) {
124- l := NewIdentityRateLimiter()
125- ctx := context.Background()
126-
127- // Flood past maxTrackedIdentities with distinct identities.
128- for i := 0; i < maxTrackedIdentities+10; i++ {
129- if _, err := l.allow(ctx, "flooding-caller", string(rune(i))+"-flood"); err != nil {
130- t.Fatalf("allow: %v", err)
131- }
132- }
133-
134- // A brand-new identity past the cap must still be ALLOWED (fails
135- // open on this dimension only, per this type's own doc comment) —
136- // never silently denying service to a legitimate new identity just
137- // because the tracked-key set is full.
138- allowed, err := l.allow(ctx, "flooding-caller", "one-more-new-identity")
139- if err != nil {
140- t.Fatalf("allow: %v", err)
141- }
142- if !allowed {
143- t.Errorf("a new identity past maxTrackedIdentities was denied — this dimension must fail OPEN (allow), not closed, once the tracked-key cap is reached")
144- }
145-}{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -n \"KeySource\\|JWKSCache\\|connectorJWKSCacheTTL\" internal/app/connector_router.go cmd/idp-connector/main.go internal/app/connector_router_test.go"
}internal/app/connector_router.go:13:// connectorJWKSCacheTTL matches api-service's own verifier JWKS TTL internal/app/connector_router.go:17:const connectorJWKSCacheTTL = 15 * time.Minute internal/app/connector_router.go:37:// Builds its own JWKSCache from cfg.AuthJWKSURL (same pattern as internal/app/connector_router.go:50: keys := api.NewJWKSCache(cfg.AuthJWKSURL, connectorJWKSCacheTTL, nil)
{
"file_path": "/Users/warrenthompson/Source/LoginID-worktrees/LT-41/internal/app/connector_router.go"
}1 package app
2
3 import (
4 "log"
5 "net/http"
6 "time"
7
8 "loginid-takehome/internal/api"
9 "loginid-takehome/internal/config"
10 "loginid-takehome/internal/connector"
11 )
12
13 // connectorJWKSCacheTTL matches api-service's own verifier JWKS TTL
14 // (internal/app/verifier_router.go) — both binaries verify tokens minted
15 // by the same issuer, so there's no reason for a different refresh
16 // cadence.
17 const connectorJWKSCacheTTL = 15 * time.Minute
18
19 // connectorVendorLimiterBaseBackoff/MaxBackoff/AlertThreshold mirror
20 // NewIssuerRouter's own GrantLimiter constants — same reasoning
21 // (handoff-03-auth.md names the mechanism, not specific numbers), reused
22 // here for the vendor-call rate limiter per LT-41's attack-tree leaf 5
23 // control rather than inventing a second set of magic numbers.
24 const (
25 connectorVendorLimiterBaseBackoff = 1 * time.Second
26 connectorVendorLimiterMaxBackoff = 5 * time.Minute
27 connectorVendorLimiterAlertThreshold = 5
28 )
29
30 // NewConnectorRouter builds cmd/idp-connector's real router — LT-41's
31 // /auth and /identity handlers behind the connector's own inbound JWT
32 // verification (a distinct audience, connector-security.md §5: aud =
33 // cfg.ConnectorJWTAudience, scope = connector:identity-lookup — NOT
34 // api-service's audience, so a profile:*-scoped api-service token
35 // cannot be replayed against this connector and vice versa).
36 //
37 // Builds its own JWKSCache from cfg.AuthJWKSURL (same pattern as
38 // NewVerifierRouter) rather than taking one as a parameter — both
39 // binaries verify tokens minted by the same issuer, at the same TTL.
40 //
41 // vendor is the outbound seam to the third-party IDP — either a real
42 // (HTTPVendorClient) or stub (StubVendorClient) implementation;
43 // cmd/idp-connector/main.go decides which based on whether
44 // IDP_ABC_BASE_URL is configured.
45 func NewConnectorRouter(cfg config.Config, vendor connector.VendorClient) http.Handler {
46 mux := http.NewServeMux()
47 mux.HandleFunc("/healthz", healthHandler("idp-connector"))
48
49 auditLog := api.StdoutAuditLogger{}
50 keys := api.NewJWKSCache(cfg.AuthJWKSURL, connectorJWKSCacheTTL, nil)
51 authMiddleware := api.NewJWTMiddleware(keys, cfg.AuthJWTIssuer, cfg.ConnectorJWTAudience, auditLog)
52 protect := func(h http.Handler) http.Handler {
53 return api.NewDeadlineMiddleware(authMiddleware(h))
54 }
55
56 // One shared limiter instance across both handlers — a caller
57 // hammering /auth and a caller hammering /identity are the same
58 // abuse signal against the same vendor, keyed by the same
59 // (calling-API-client sub, source IP) pair (LT-41 attack-tree
60 // leaf 5). Reuses api.GrantLimiter (PR #30/#34) rather than
61 // building a new rate-limit type, per this story's own non-goals.
62 limiter := api.NewInProcessGrantLimiter(
63 connectorVendorLimiterBaseBackoff, connectorVendorLimiterMaxBackoff, connectorVendorLimiterAlertThreshold,
64 func(clientID string, consecutiveFailures int) {
65 log.Printf("idp-connector: ALERT sustained failed vendor calls from caller sub=%s (%d consecutive)", clientID, consecutiveFailures)
66 },
67 )
68
69 // The second, per-(caller, attempted identity) dimension leaf 5
70 // actually specifies — see internal/connector/identitylimiter.go's
71 // own doc comment (Tomasz Wrede's cold review of PR #40).
72 identityLimiter := connector.NewIdentityRateLimiter()
73
74 deps := connector.Deps{Vendor: vendor, Limiter: limiter, IdentityLimiter: identityLimiter, AuditLog: auditLog}
75 mux.Handle("POST /auth", protect(connector.NewAuthHandler(deps)))
76 mux.Handle("POST /identity", protect(connector.NewIdentityHandler(deps)))
77
78 return mux
79 }
80 {
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -n \"NewConnectorRouter\" -B2 -A2 cmd/idp-connector/main.go internal/app/connector_router_test.go"
}cmd/idp-connector/main.go-59-
cmd/idp-connector/main.go-60- log.Printf("idp-connector: listening on %s", addr)
cmd/idp-connector/main.go:61: if err := http.ListenAndServe(addr, app.NewConnectorRouter(cfg, vendor)); err != nil {
cmd/idp-connector/main.go-62- log.Fatalf("idp-connector: %v", err)
cmd/idp-connector/main.go-63- }
internal/app/connector_router_test.go-44-}
internal/app/connector_router_test.go-45-
internal/app/connector_router_test.go:46:func TestNewConnectorRouter_HealthzUnaffectedByAuth(t *testing.T) {
internal/app/connector_router_test.go-47- cfg := config.Config{AuthJWTIssuer: testIssuer, ConnectorJWTAudience: testConnectorAudience, AuthJWKSURL: "http://unused.invalid"}
internal/app/connector_router_test.go:48: router := NewConnectorRouter(cfg, newTestStubVendor(t))
internal/app/connector_router_test.go-49-
internal/app/connector_router_test.go-50- req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
--
internal/app/connector_router_test.go-56-}
internal/app/connector_router_test.go-57-
internal/app/connector_router_test.go:58:func TestNewConnectorRouter_NoToken_401(t *testing.T) {
internal/app/connector_router_test.go-59- key, err := rsa.GenerateKey(rand.Reader, 2048)
internal/app/connector_router_test.go-60- if err != nil {
--
internal/app/connector_router_test.go-65-
internal/app/connector_router_test.go-66- cfg := config.Config{AuthJWTIssuer: testIssuer, ConnectorJWTAudience: testConnectorAudience, AuthJWKSURL: jwks.URL}
internal/app/connector_router_test.go:67: router := NewConnectorRouter(cfg, newTestStubVendor(t))
internal/app/connector_router_test.go-68-
internal/app/connector_router_test.go-69- req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(`{"username":"demo-user","password":"demo-password"}`))
--
internal/app/connector_router_test.go-75-}
internal/app/connector_router_test.go-76-
internal/app/connector_router_test.go:77:// TestNewConnectorRouter_WrongAudience_Rejected is LT-41's own named
internal/app/connector_router_test.go-78-// enforcement site (connector-security.md §5 / PO review script item
internal/app/connector_router_test.go-79-// 3): a token minted for api-service's audience (loginid-api-service)
internal/app/connector_router_test.go-80-// must not work against the connector, which requires its own distinct
internal/app/connector_router_test.go-81-// audience (idp-connector-service).
internal/app/connector_router_test.go:82:func TestNewConnectorRouter_WrongAudience_Rejected(t *testing.T) {
internal/app/connector_router_test.go-83- key, err := rsa.GenerateKey(rand.Reader, 2048)
internal/app/connector_router_test.go-84- if err != nil {
--
internal/app/connector_router_test.go-89-
internal/app/connector_router_test.go-90- cfg := config.Config{AuthJWTIssuer: testIssuer, ConnectorJWTAudience: testConnectorAudience, AuthJWKSURL: jwks.URL}
internal/app/connector_router_test.go:91: router := NewConnectorRouter(cfg, newTestStubVendor(t))
internal/app/connector_router_test.go-92-
internal/app/connector_router_test.go-93- wrongAudienceToken := signConnectorTestToken(t, key, "key-1", "loginid-api-service", "connector:identity-lookup")
--
internal/app/connector_router_test.go-101-}
internal/app/connector_router_test.go-102-
internal/app/connector_router_test.go:103:func TestNewConnectorRouter_CorrectAudienceAndScope_ReachesAuthHandler(t *testing.T) {
internal/app/connector_router_test.go-104- key, err := rsa.GenerateKey(rand.Reader, 2048)
internal/app/connector_router_test.go-105- if err != nil {
--
internal/app/connector_router_test.go-110-
internal/app/connector_router_test.go-111- cfg := config.Config{AuthJWTIssuer: testIssuer, ConnectorJWTAudience: testConnectorAudience, AuthJWKSURL: jwks.URL}
internal/app/connector_router_test.go:112: router := NewConnectorRouter(cfg, newTestStubVendor(t))
internal/app/connector_router_test.go-113-
internal/app/connector_router_test.go-114- tok := signConnectorTestToken(t, key, "key-1", testConnectorAudience, "connector:identity-lookup")
--
internal/app/connector_router_test.go-122-}
internal/app/connector_router_test.go-123-
internal/app/connector_router_test.go:124:func TestNewConnectorRouter_IdentityEndToEnd(t *testing.T) {
internal/app/connector_router_test.go-125- key, err := rsa.GenerateKey(rand.Reader, 2048)
internal/app/connector_router_test.go-126- if err != nil {
--
internal/app/connector_router_test.go-132- cfg := config.Config{AuthJWTIssuer: testIssuer, ConnectorJWTAudience: testConnectorAudience, AuthJWKSURL: jwks.URL}
internal/app/connector_router_test.go-133- vendor := newTestStubVendor(t)
internal/app/connector_router_test.go:134: router := NewConnectorRouter(cfg, vendor)
internal/app/connector_router_test.go-135- tok := signConnectorTestToken(t, key, "key-1", testConnectorAudience, "connector:identity-lookup")
internal/app/connector_router_test.go-136-{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -rn \"KeySource\" . --include=*.go --include=*.md"
}(eval):1: no matches found: --include=*.go
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -rn \"KeySource\" --include=\"*.go\" --include=\"*.md\" . 2>/dev/null; echo done"
}internal/api/jwtmiddleware.go:26:// library ever calls KeySource — jwt/v5's WithValidMethods rejects a
internal/api/jwtmiddleware.go:48: keys KeySource
internal/api/jwtmiddleware.go:150:// structurally, since KeySource's real implementation, JWKSCache, only
internal/api/jwtmiddleware.go:153:// KeySource backed by the issuer's full published key set, not a single
internal/api/jwtmiddleware.go:157:// exception: a KeySource failing closed on exceeded JWKS staleness
internal/api/jwtmiddleware.go:163:func NewJWTMiddleware(keys KeySource, issuer, audience string, audit AuditLogger) func(http.Handler) http.Handler {
internal/api/jwks.go:85:// KeySource resolves a kid to the RSA public key that should verify a
internal/api/jwks.go:92:type KeySource interface {
internal/api/jwtmiddleware_test.go:21:// fakeKeySource implements KeySource with an in-memory map, for tests
internal/api/jwtmiddleware_test.go:24:type fakeKeySource struct {
internal/api/jwtmiddleware_test.go:28:func (f *fakeKeySource) KeyForKid(_ context.Context, kid string) (*rsa.PublicKey, error) {
internal/api/jwtmiddleware_test.go:102: return NewJWTMiddleware(&fakeKeySource{keys: keys}, testIssuer, testAudience, &fakeAuditLogger{})
internal/api/signingkey.go:7:// (LT-40's own structural guarantee is unaffected: KeySource/JWKSCache
internal/api/issuer_verifier_integration_test.go:17:// cache and its real HTTP fetch, not a fake KeySource.
done{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && sed -n '1,80p' internal/connector/handlers.go"
}package connector
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
"loginid-takehome/internal/api"
)
// identityFailureFloor is /identity's uniform response-time floor across
// its three failure branches (no-token, bad-token, vendor-rejected) —
// Marcus Ilori's (02) ruling on Tobias Lindqvist's timing-oracle
// objection (refinement/LT-41.md), recorded in full in
// connector-security.md §4. ~100ms, well under any context.WithTimeout
// budget elsewhere in the chain (vendorCallTimeout is 10s) so it never
// competes with those deadlines. This narrows, but does not fully close,
// the gap between a local-only failure (microseconds) and a
// vendor-round-trip failure (tens-hundreds of ms) — full closure is an
// accepted non-goal per that ruling.
const identityFailureFloor = 100 * time.Millisecond
// identityErrorBody is the one JSON shape every /identity failure
// returns, regardless of which of the three branches produced it —
// content-identical by construction (a single writeIdentityFailure call
// site below), not by three call sites happening to independently agree.
type identityErrorBody struct {
Error string `json:"error"`
}
// vendorLimiter is the interface these handlers depend on — satisfied
// directly by api.GrantLimiter (PR #30/#34's already-built component,
// reused here rather than rebuilt per LT-41's own non-goals: "this
// story's own in-process rate limiting is sufficient, matching the
// pattern already used for RateLimiter/TouchCounter/GrantLimiter").
// Declared locally so this package's own tests can supply a minimal fake
// satisfying only the calls actually made here, per
// decisions/test-double-strategy.md.
type vendorLimiter interface {
Allow(ctx context.Context, clientID, sourceIP string) (bool, error)
RecordFailure(ctx context.Context, clientID, sourceIP string)
RecordSuccess(ctx context.Context, clientID, sourceIP string)
}
// Deps bundles NewAuthHandler/NewIdentityHandler's collaborators.
//
// IdentityLimiter is the second rate-limit dimension attack-tree leaf 5
// actually specifies ("per identity attempted, not just per client") —
// Limiter alone (keyed by calling-API-client sub + source IP) means one
// success anywhere resets that caller's whole consecutive-failure count,
// letting a compromised caller cycle through many stolen identities
// indefinitely (Tomasz Wrede's cold review, PR #40). May be nil in tests
// that don't exercise this dimension; NewConnectorRouter always
// constructs a real one.
type Deps struct {
Vendor VendorClient
Limiter vendorLimiter
IdentityLimiter *identityRateLimiter
AuditLog api.AuditLogger
}
// identityGateAllow/RecordFailure/RecordSuccess are deps.IdentityLimiter's
// call sites, tolerant of a nil IdentityLimiter (tests that only care
// about the caller-level Limiter dimension can omit it — the identity
// dimension then simply isn't enforced, never a panic).
func identityGateAllow(ctx context.Context, l *identityRateLimiter, callerSub, attemptedIdentity string) (bool, error) {
if l == nil {
return true, nil
}
return l.allow(ctx, callerSub, attemptedIdentity)
}
func identityGateRecordFailure(ctx context.Context, l *identityRateLimiter, callerSub, attemptedIdentity string) {
if l == nil {
return
}{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && sed -n '80,330p' internal/connector/handlers.go"
} }
l.recordFailure(ctx, callerSub, attemptedIdentity)
}
func identityGateRecordSuccess(ctx context.Context, l *identityRateLimiter, callerSub, attemptedIdentity string) {
if l == nil {
return
}
l.recordSuccess(ctx, callerSub, attemptedIdentity)
}
func sourceIPFrom(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeIdentityFailure is the single call site for every /identity
// failure response — the one place that guarantees byte-identical
// content across the no-token, bad-token, and vendor-rejected branches
// (connector-security.md §1/F3), never three call sites independently
// trying to agree.
func writeIdentityFailure(w http.ResponseWriter) {
writeJSON(w, http.StatusUnauthorized, identityErrorBody{Error: "identity_lookup_failed"})
}
// waitForFloor sleeps out the remainder of identityFailureFloor since
// start, if any — applied uniformly across /identity's three failure
// branches so none of them is distinguishable from the others by
// response latency alone (partial mitigation per connector-security.md
// §4's own accepted-residual language; not applied to the success path,
// which was never part of the timing-oracle concern).
func waitForFloor(start time.Time) {
if remaining := identityFailureFloor - time.Since(start); remaining > 0 {
time.Sleep(remaining)
}
}
// authRequestBody is the assignment's own fixed POST /auth contract.
type authRequestBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authResponseBody struct {
AccessToken string `json:"access_token"`
}
// NewAuthHandler implements POST /auth. The caller (in this project's
// shape, cmd/api-service acting on an onboarding flow's behalf) must
// already be authenticated with a connector:identity-lookup-scoped,
// idp-connector-service-audience token — enforced by whatever middleware
// wraps this handler (internal/app.NewConnectorRouter), not by this
// function; AuthContextFromContext here is read-only identification for
// rate-limiting and audit purposes.
func NewAuthHandler(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ac, _ := api.AuthContextFromContext(ctx)
sourceIP := sourceIPFrom(r)
allowed, err := deps.Limiter.Allow(ctx, ac.Sub, sourceIP)
if err != nil {
writeJSON(w, http.StatusInternalServerError, identityErrorBody{Error: "server_error"})
return
}
if !allowed {
writeJSON(w, http.StatusTooManyRequests, identityErrorBody{Error: "slow_down"})
return
}
var body authRequestBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, identityErrorBody{Error: "invalid_request"})
return
}
// Second rate-limit dimension — per (caller, attempted vendor
// username), not just per caller (attack-tree leaf 5, Tomasz
// Wrede's cold review of PR #40): gates BEFORE the vendor call,
// same as the caller-level Limiter above.
identityAllowed, err := identityGateAllow(ctx, deps.IdentityLimiter, ac.Sub, body.Username)
if err != nil {
writeJSON(w, http.StatusInternalServerError, identityErrorBody{Error: "server_error"})
return
}
if !identityAllowed {
writeJSON(w, http.StatusTooManyRequests, identityErrorBody{Error: "slow_down"})
return
}
// accessToken is scoped to this block only — read from the
// vendor call's return value, written straight into the
// response, never assigned to any wider-scoped variable or
// logged. This IS the narrowest this value can be scoped given
// /auth's own contract: its job is delivering the token to the
// caller, so it necessarily reaches the response writer, but
// nothing else.
accessToken, err := deps.Vendor.Authenticate(ctx, body.Username, body.Password)
if err != nil {
deps.Limiter.RecordFailure(ctx, ac.Sub, sourceIP)
identityGateRecordFailure(ctx, deps.IdentityLimiter, ac.Sub, body.Username)
logAuditOutcome(ctx, deps.AuditLog, ac.Sub, api.AuditAuthnFailure)
// Generic failure regardless of *why* the vendor rejected
// it (unknown username vs. wrong password vs. vendor
// unreachable) — connector-security.md §4: "must not leak
// which part of the credential was wrong."
writeJSON(w, http.StatusUnauthorized, identityErrorBody{Error: "vendor_authentication_failed"})
return
}
deps.Limiter.RecordSuccess(ctx, ac.Sub, sourceIP)
identityGateRecordSuccess(ctx, deps.IdentityLimiter, ac.Sub, body.Username)
logAuditOutcome(ctx, deps.AuditLog, ac.Sub, api.AuditSuccess)
writeJSON(w, http.StatusOK, authResponseBody{AccessToken: accessToken})
}
}
// identityRequestBody is the assignment's own fixed POST /identity
// contract — no credential field, since the vendor token travels as a
// request header instead (connector-security.md §1).
type identityRequestBody struct {
Phone string `json:"phone"`
Name string `json:"name"`
}
type identityResponseBody struct {
Name string `json:"name"`
Phone string `json:"phone"`
StreetAddress string `json:"street_address"`
Locality string `json:"locality"`
Region string `json:"region"`
PostalCode string `json:"postal_code"`
Country string `json:"country"`
}
// vendorAccessTokenHeader is the header /identity accepts the vendor's
// access_token on — a header, not a body field, since the assignment's
// fixed {"phone","name"} body has no credential field (LT-41 acceptance
// criteria).
const vendorAccessTokenHeader = "X-Vendor-Access-Token"
// NewIdentityHandler implements POST /identity. Every failure path
// (missing token, malformed-looking token, vendor rejection) returns
// through writeIdentityFailure after waitForFloor — the two calls
// together are what make the three branches content- and
// (approximately) time-identical, per connector-security.md §1/§4.
func NewIdentityHandler(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ctx := r.Context()
ac, _ := api.AuthContextFromContext(ctx)
sourceIP := sourceIPFrom(r)
allowed, err := deps.Limiter.Allow(ctx, ac.Sub, sourceIP)
if err != nil {
writeJSON(w, http.StatusInternalServerError, identityErrorBody{Error: "server_error"})
return
}
if !allowed {
// Not one of the three timing-oracle branches (it's an
// overt, openly-disclosed 429, not a covert signal) — no
// floor applied.
writeJSON(w, http.StatusTooManyRequests, identityErrorBody{Error: "slow_down"})
return
}
var body identityRequestBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
// A malformed body is a caller bug, not one of the three
// named credential/token-validity branches — ordinary 400,
// no floor.
writeJSON(w, http.StatusBadRequest, identityErrorBody{Error: "invalid_request"})
return
}
// Second rate-limit dimension — per (caller, attempted
// phone+name), not just per caller (attack-tree leaf 5, Tomasz
// Wrede's cold review of PR #40). Not one of the three
// timing-oracle branches (an overt 429, same as the caller-level
// gate above) — no floor.
attemptedIdentity := body.Phone + "|" + body.Name
identityAllowed, err := identityGateAllow(ctx, deps.IdentityLimiter, ac.Sub, attemptedIdentity)
if err != nil {
writeJSON(w, http.StatusInternalServerError, identityErrorBody{Error: "server_error"})
return
}
if !identityAllowed {
writeJSON(w, http.StatusTooManyRequests, identityErrorBody{Error: "slow_down"})
return
}
token := strings.TrimSpace(r.Header.Get(vendorAccessTokenHeader))
if token == "" {
deps.Limiter.RecordFailure(ctx, ac.Sub, sourceIP)
identityGateRecordFailure(ctx, deps.IdentityLimiter, ac.Sub, attemptedIdentity)
logAuditOutcome(ctx, deps.AuditLog, ac.Sub, api.AuditAuthnFailure)
waitForFloor(start)
writeIdentityFailure(w)
return
}
// token is scoped to this function only, passed by value into
// FetchIdentity, and never assigned to any variable outside
// this block or returned — attack-tree leaf 4's control
// (connector-security.md §1/Attack-tree row 4).
identity, err := deps.Vendor.FetchIdentity(ctx, token, body.Phone, body.Name)
if err != nil {
deps.Limiter.RecordFailure(ctx, ac.Sub, sourceIP)
identityGateRecordFailure(ctx, deps.IdentityLimiter, ac.Sub, attemptedIdentity)
logAuditOutcome(ctx, deps.AuditLog, ac.Sub, api.AuditAuthnFailure)
waitForFloor(start)
writeIdentityFailure(w)
return
}
deps.Limiter.RecordSuccess(ctx, ac.Sub, sourceIP)
identityGateRecordSuccess(ctx, deps.IdentityLimiter, ac.Sub, attemptedIdentity)
logAuditOutcome(ctx, deps.AuditLog, ac.Sub, api.AuditSuccess)
// Success is not part of the timing-oracle concern — no floor.
// identityResponseBody's fields are identical in name/order/type
// to Identity (only the json tags differ), so a direct type
// conversion is both valid and clearer than restating every
// field (staticcheck S1016).
writeJSON(w, http.StatusOK, identityResponseBody(identity))
}
}
// logAuditOutcome logs caller sub/scope/timestamp/outcome only — never
// the vendor username/password, vendor access_token, or any PII field
// value, per connector-security.md §3/§5's audit discipline.
func logAuditOutcome(ctx context.Context, auditLog api.AuditLogger, sub string, kind api.AuditEventKind) {
if auditLog == nil {
return
}
auditLog.Log(ctx, api.AuditEvent{
Sub: sub,
Scope: api.ScopeConnectorIdentityLookup,
Kind: kind,
Timestamp: time.Now().UTC(),
})
}{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && cat internal/connector/identitylimiter_test.go"
}package connector
import (
"context"
"testing"
)
// TestIdentityRateLimiter_StolenIdentitiesSurviveAnUnrelatedSuccess is
// Tomasz Wrede's named attack scenario (cold review of PR #40): the
// caller-level GrantLimiter alone resets its ENTIRE consecutive-failure
// count on any success, so a compromised caller cycling through many
// stolen identities can keep a fresh allowance indefinitely as long as
// it occasionally succeeds against ONE of them (e.g. one seed valid
// account). Per-(caller, identity) keying must not have that property:
// a specific identity's own backoff must trip on its own repeated
// failures and must NOT be reset by a completely different identity
// succeeding under the same caller.
func TestIdentityRateLimiter_StolenIdentitiesSurviveAnUnrelatedSuccess(t *testing.T) {
l := NewIdentityRateLimiter()
ctx := context.Background()
const caller = "api-service"
// Fail against "victim-1" enough times to trip its own backoff.
blockedAfter := -1
for i := 0; i < 10; i++ {
allowed, err := l.allow(ctx, caller, "victim-1")
if err != nil {
t.Fatalf("allow: %v", err)
}
if !allowed {
blockedAfter = i
break
}
l.recordFailure(ctx, caller, "victim-1")
}
if blockedAfter == -1 {
t.Fatalf("victim-1 was never blocked across 10 consecutive failures")
}
// The attacker now succeeds against a COMPLETELY DIFFERENT identity
// under the same caller (the "one valid seed account" case).
allowed, err := l.allow(ctx, caller, "victim-2")
if err != nil {
t.Fatalf("allow: %v", err)
}
if !allowed {
t.Fatalf("victim-2 (never attempted before) was blocked — test setup bug")
}
l.recordSuccess(ctx, caller, "victim-2")
// victim-1 must STILL be blocked — victim-2's success must not have
// reset it. This is exactly the property a single shared per-caller
// bucket does NOT have (a success there resets everything).
allowed, err = l.allow(ctx, caller, "victim-1")
if err != nil {
t.Fatalf("allow: %v", err)
}
if allowed {
t.Errorf("victim-1 is allowed again after an unrelated identity (victim-2) succeeded — a per-identity bucket must not be resettable by a different identity's success")
}
}
// TestIdentityRateLimiter_TypoIsolation is Tomasz Wrede's other named
// scenario: two different LEGITIMATE users behind the same caller, one
// of whom mistypes their credential once, must not affect the other —
// "strangers' typos" must not over-limit an unrelated identity.
func TestIdentityRateLimiter_TypoIsolation(t *testing.T) {
l := NewIdentityRateLimiter()
ctx := context.Background()
const caller = "api-service"
// user-A mistypes once — this correctly starts user-A's OWN backoff
// (same progressive-backoff-from-the-first-failure design as
// api.GrantLimiter elsewhere in this codebase; not itself the
// property under test here) — then eventually succeeds.
if allowed, err := l.allow(ctx, caller, "user-A"); err != nil || !allowed {
t.Fatalf("user-A first attempt: allowed=%v err=%v", allowed, err)
}
l.recordFailure(ctx, caller, "user-A")
l.recordSuccess(ctx, caller, "user-A")
// user-B, a completely different identity under the same caller,
// must never have been affected by user-A's typo — first attempt
// must be allowed.
allowed, err := l.allow(ctx, caller, "user-B")
if err != nil {
t.Fatalf("allow: %v", err)
}
if !allowed {
t.Errorf("user-B was blocked by user-A's unrelated typo under the same caller — per-identity isolation must prevent this")
}
}
func TestIdentityRateLimiter_DifferentCallersSameIdentityDoNotShareState(t *testing.T) {
l := NewIdentityRateLimiter()
ctx := context.Background()
// caller-1 fails repeatedly against "shared-name" (both callers
// happen to submit the same attempted-identity string).
for i := 0; i < 10; i++ {
allowed, err := l.allow(ctx, "caller-1", "shared-name")
if err != nil {
t.Fatalf("allow: %v", err)
}
if !allowed {
break
}
l.recordFailure(ctx, "caller-1", "shared-name")
}
// caller-2 attempting the SAME identity string must not be affected
// — the key is composite over (caller, identity), per Marcus Ilori's
// ruling, not identity alone.
allowed, err := l.allow(ctx, "caller-2", "shared-name")
if err != nil {
t.Fatalf("allow: %v", err)
}
if !allowed {
t.Errorf("caller-2 was blocked by caller-1's failures against the same identity string — the key must be composite over (caller, identity)")
}
}
func TestIdentityRateLimiter_BoundedCardinality_DegradesOpenNotClosed(t *testing.T) {
l := NewIdentityRateLimiter()
ctx := context.Background()
// Flood past maxTrackedIdentities with distinct identities.
for i := 0; i < maxTrackedIdentities+10; i++ {
if _, err := l.allow(ctx, "flooding-caller", string(rune(i))+"-flood"); err != nil {
t.Fatalf("allow: %v", err)
}
}
// A brand-new identity past the cap must still be ALLOWED (fails
// open on this dimension only, per this type's own doc comment) —
// never silently denying service to a legitimate new identity just
// because the tracked-key set is full.
allowed, err := l.allow(ctx, "flooding-caller", "one-more-new-identity")
if err != nil {
t.Fatalf("allow: %v", err)
}
if !allowed {
t.Errorf("a new identity past maxTrackedIdentities was denied — this dimension must fail OPEN (allow), not closed, once the tracked-key cap is reached")
}
}{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && grep -n \"IdentityLimiter_WiredThroughCorrectly\" -A 50 internal/connector/handlers_test.go | head -70"
}87:// TestNewAuthHandler_IdentityLimiter_WiredThroughCorrectly proves the
88-// handler-level wiring, not just the identityRateLimiter component in
89-// isolation: repeated failures against ONE attempted vendor username
90-// eventually block further attempts against THAT username specifically,
91-// while a request attempting a DIFFERENT username under the same caller
92-// is unaffected — attack-tree leaf 5's per-identity dimension actually
93-// reaches the handler, not just the component's own unit tests.
94:func TestNewAuthHandler_IdentityLimiter_WiredThroughCorrectly(t *testing.T) {
95- vendor := &fakeVendorClient{
96- authFn: func(context.Context, string, string) (string, error) { return "", ErrVendorAuthFailed },
97- }
98- limiter := api.NewInProcessGrantLimiter(time.Millisecond, time.Hour, 1000, nil) // caller-level: high threshold, not what's under test
99- identityLimiter := NewIdentityRateLimiter()
100- h := NewAuthHandler(Deps{Vendor: vendor, Limiter: limiter, IdentityLimiter: identityLimiter, AuditLog: api.StdoutAuditLogger{}})
101-
102- post := func(username string) int {
103- req := withAuthContext("caller-1")(httptest.NewRequest(http.MethodPost, "/auth",
104- strings.NewReader(`{"username":"`+username+`","password":"wrong"}`)))
105- w := httptest.NewRecorder()
106- h.ServeHTTP(w, req)
107- return w.Code
108- }
109-
110- blockedAfter := -1
111- for i := 0; i < 10; i++ {
112- code := post("victim-username")
113- if code == http.StatusTooManyRequests {
114- blockedAfter = i
115- break
116- }
117- if code != http.StatusUnauthorized {
118- t.Fatalf("attempt %d: status = %d, want 401 or 429", i, code)
119- }
120- }
121- if blockedAfter == -1 {
122- t.Fatalf("repeated failures against the same username were never rate-limited (429) — identity-level wiring did not engage")
123- }
124-
125- // Let the CALLER-level limiter's own (short, 1ms-base) backoff decay
126- // — this test's caller-level limiter is deliberately permissive so
127- // the identity dimension is what's under test, but it still applies
128- // its own brief backoff after each failure; the identity-level
129- // limiter's backoff (a full second, unaffected by this sleep) is
130- // what should still be blocking "victim-username" specifically.
131- time.Sleep(10 * time.Millisecond)
132-
133- // A DIFFERENT username under the same caller must still be reachable
134- // (401 for the wrong password, not 429) — proving the block is
135- // per-identity, not per-caller, at the handler level.
136- if code := post("a-different-username"); code != http.StatusUnauthorized {
137- t.Errorf("a different username was blocked (status %d) by an unrelated username's failures under the same caller", code)
138- }
139-}
140-
141-// TestNewIdentityHandler_ThreeFailureBranches_ContentIdenticalAndTimingClose
142-// is the enforcement site for both F3 (content) and Marcus Ilori's
143-// ~100ms floor ruling (timing) — refinement/LT-41.md's own named test
144-// shape: no-token, bad-token, and vendor-rejected must produce{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees/LT-41 && sed -n '1,30p' internal/connector/vendorclient.go"
}// Package connector implements Q3 — cmd/idp-connector's outbound calls
// to a generic third-party identity provider ("ABC"/"XYZ" in the
// assignment's own generic framing), per
// 02-ai-security-architecture/connector-security.md and
// refinement/LT-41.md. No real vendor integration exists or is asked
// for (LT-41's own non-goals) — VendorClient is the seam a real vendor
// adapter would implement; this package ships a stub (stubvendorclient.go)
// good enough to demonstrate the connector's own contract and security
// properties end to end, and an HTTP-based implementation
// (httpvendorclient.go) that demonstrates the outbound security
// discipline connector-security.md requires, callable against any vendor
// that happens to share this project's own JSON shape (a stated
// simplifying assumption, not a claim about any real vendor's actual
// API).
package connector
import "context"
// Identity is the PII a vendor's /identity endpoint returns, matching
// the assignment's own field list verbatim — name, phone, and address
// (street_address, locality, region, postal_code, country).
type Identity struct {
Name string
Phone string
StreetAddress string
Locality string
Region string
PostalCode string
Country string
}