Skip to content

rescue: unpushed work from station01 worktree (idp-fed-harden-work) - #205

Open
andrei-hasna wants to merge 10 commits into
mainfrom
idp-fed-harden-work
Open

rescue: unpushed work from station01 worktree (idp-fed-harden-work)#205
andrei-hasna wants to merge 10 commits into
mainfrom
idp-fed-harden-work

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Rescued unpushed work from a station01 worktree

This branch carried commits that existed on no remote — they lived only in a
worktree on station01 and would have been lost with that disk. Pushing them is a
rescue, not new work.

Found by the station01 worktree census (worktree-fleet-cleanup). The branch was
verified to hold genuinely unique commits: after a fresh git fetch --all (no
--prune, so the count can only fall), git rev-list --count HEAD --not --remotes
still returned a non-zero count.

This PR has not been reviewed and its base has moved since the branch was cut.
Treat it as a recovered artifact to triage, not as a ready-to-merge change. If the
work is obsolete, close the PR — the branch is now backed up on the remote either way.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…authority

An IdP-federated principal could never send: the send gate's
allowTenantWideSend input recognized only the apikey class and role-bearing
sessions, the idp class carries no role, and minting a send key is
operator-gated on the wildcard scope. Every federated POST /v1/messages/send
was therefore a dead-end 403 send_key_required.

The idp principal class now carries tenant-wide send authority exactly when
its (already tenant-scoped) grant includes emails:write. Read-only grants and
unmapped principals are still refused, typed, before any send machinery runs.
…e-if-error with a ceiling

Publishing an empty (or Ed25519-free) JWKS is how an IdP fully withdraws a
compromised signing key, and the authenticator treated it as a fetch error:
parseJwksDocument returned null for zero keys, refresh() kept the stale set,
and removed keys stayed trusted until a process restart. A well-formed
document now ALWAYS replaces the cache — empty included — while a malformed
document or fetch failure remains the stale-if-error path.

Stale-if-error is now also bounded: past a max-staleness ceiling (default
3600s, EMAILS_IDP_JWKS_MAX_STALE_SECONDS, never below the cache TTL) the
unrefreshable key set is discarded and verification fails with the typed 503
the module header promises, instead of trusting unverifiable keys forever.
…t no longer resurrects a revoked mapping

The table was keyed on sub alone with ON CONFLICT (sub) DO UPDATE ...
revoked_at = NULL, so granting a principal tenant B silently and unauditably
revoked its tenant-A grant, and re-running a grant for a principal an operator
had deliberately killed un-revoked it.

Additive migration 0022 keys the table on (sub, tenant_id) — the unique
composite index is created before the sub-only primary key is dropped, so
uniqueness never lapses — and the upsert's conflict arm now updates the
descriptive columns only: revoked_at is untouchable by a re-grant and is
lifted only by the new explicit restoreIdpPrincipalTenant. Revocation gains a
tenant-scoped form plus the unscoped all-grants incident form. Resolution
handles multiple grants with the most specific typed refusal, including the
new fail-closed idp_grant_ambiguous instead of silently picking a tenant.

Verified against a real Postgres: migration idempotency, coexisting grants
with independent kill switches, and the no-resurrect round trip.
upsertIdpPrincipalTenant/revokeIdpPrincipalTenant had zero non-test callers:
no grant row could exist without hand SQL against production, leaving the
whole federation slice inert (every verified token 403 no_tenant), and the
revoked_at kill switch — the only revocation emails can enforce inside a
token's <=24h life — was equally unreachable during an incident.

New routes, operator-gated exactly like send-key minting (tenant owner/admin
or wildcard key; bare emails:write refused typed operator_required; tenant is
always the caller's resolved tenant, never a parameter):

  GET    /v1/idp-principals               list grants, revoked included
  POST   /v1/idp-principals               grant { sub, idp_tid?, ... }
  POST   /v1/idp-principals/{sub}/revoke  throw the kill switch
  DELETE /v1/idp-principals/{sub}         same operation
  POST   /v1/idp-principals/{sub}/restore deliberately lift the kill switch

Plus the matching CLI verbs (emails self-hosted idp-principal
grant/revoke/restore/list) against the server's own database like
'self-hosted key' — revoke without --tenant kills every grant of the sub in
one command, the incident path. Routes are published in the OpenAPI document.
…bf, own-tenant read, reconcile audit

Four gaps with one cause (the idp class was added to the resolver but not to
everything downstream):

- claims.pt is now compared to the grant's principal_type; a 'user' token no
  longer authenticates against a 'service' grant or vice versa (typed
  idp_principal_type_mismatch). The column existed precisely to be checked.
- verifyIdpToken enforces the pinned wire contract's header typ 'at+jwt'
  (typed unsupported_typ), and looksLikeIdpToken keys the credential class on
  the declared typ instead of the signature algorithm — an EdDSA JWS the same
  IdP key signs for another purpose no longer routes into (or through) the
  access-token verifier. nbf is read and enforced with leeway when present.
- GET /v1/tenants/{id}: an IdP principal can read ITS OWN tenant (the id
  /v1/me returns) instead of 404; foreign tenants remain 404.
- Send-intent reconciliation records resolvedBy as idp:<sub> instead of the
  literal 'apikey:unknown'.
…e [api-auth] audit line

contracts 0.8.2 added the signed, tamper-evident tid tenant claim and nothing
consumed it. The api_key_tenants DB mapping stays the authority for which
tenant a key acts in (a client-presented claim must never pick the tenant),
but a key whose signed tid names tenant A while the mapping points at tenant
B is now a typed 403 tenant_mismatch instead of silently acting in B.
Untenanted (pre-tid) keys — every key this server's own mint paths produce
today — are untouched; the api-key credential class keeps working.

The [api-auth] audit line (extracted to formatApiAuthAuditLine, testable)
now carries tid, so both credential classes have tenant-attributable audit;
the verifier already computed it onto the event and the line dropped it.
…s and pin 0022 as the migration tail

Two rebase-surfaced fixes, no behaviour change:

- EMAILS_IDP_JWKS_MAX_STALE_SECONDS is a new active runtime environment input
  (buildIdpAuthenticatorFromEnv), so the hermetic harness must scrub it or the
  wire-regression coverage assertion refuses the run.

- src/server/self-hosted/inbound.test.ts pinned 0021_idp_principal_tenants as
  the LAST migration; this branch adds 0022_idp_principal_tenants_multi_grant
  after it, measured as the actual tail of the rebased tree. 0021 stays pinned
  as present.
…scrub

The legacy hosted env-unset bridge in scripts/run-hermetic-tests.sh is
sha256-pinned by design; adding EMAILS_IDP_JWKS_MAX_STALE_SECONDS to the scrub
list is exactly the kind of deliberate utility change the pin exists to make
loud. Recomputed over the anchored run_scrubbed body and re-pinned.
…rface

The operator surface added listIdpPrincipals/grantIdpPrincipal/revokeIdpPrincipal
endpoints but the generated self-hosted SDK was not regenerated, so the CI
'Verify generated SDK is committed' gate failed. Regenerate src/selfhost.ts and
src/lib/self-hosted-response-contracts.generated.ts from the live route specs.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #205 @ 7ae4de2 — lens: correctness+security+gates, reviewer unresolved-account006 (1 of 1)

What I ran

  • git rev-parse HEAD origin/main — exit 0. Head was 7ae4de21ba03cfaf6912dffaa3934e171c21a073; freshly fetched origin/main was 6bf68314eedf6636e92e59989ae8fcba8c968402.
  • git log --oneline origin/main..HEAD — exit 0.
  • git diff origin/main...HEAD --stat — exit 0: 26 files, 2,870 insertions, 64 deletions.
  • Full git diff origin/main...HEAD -- <file> for every changed file — exit 0.
  • bun install — exit 0. Setup only; this is not the repository test result.
  • bun run test — exit 0: 4097 pass, 154 skip, 0 fail, 19654 expect() calls; 4,251 tests across 272 files.
  • git status --short — exit 0 with no output; setup and review left the worktree clean.
  • This repository declares no standalone typecheck script, so no typecheck was invented or run. No separate build gate was present in the supplied repository gate set.

What I read

I read the complete diff of all 26 changed files and surrounding source for the self-hosted request dispatcher, API-key tenant binding, IdP JWT/JWKS verification, auth service/store, PostgreSQL migrations, operator CLI/API surface, OpenAPI and generated SDK contracts, hermetic test wrapper, and all new or changed tests. I also traced the authorization decisions through request-context resolution, tenant-scoped stores, operator checks, and the accepted IdP authority ADRs.

Blocking P0/P1 findings

  • P1 — current head cannot safely integrate with current main, and its migration identity is stale. GitHub reports this PR as CONFLICTING, and a local git merge-tree against the exact merge base reproduces conflicts in the migration registry, migration assertions, generated contracts, and related guard inputs. Current main already contains the equivalent IdP hardening from PR fix(server): tenants-IdP federation hardening — send authority, JWKS revocation honesty, operator surface, claims binding, (sub,tenant) keying, tid drift #159 and assigns 0022 to the events enum check, 0023 to webhook idempotency, and 0024 to IdP multi-grant. This head still assigns IdP multi-grant to 0022, with tests and guard hashes pinned to that stale sequence. Seven of ten head commits are patch-equivalent to current main; the remaining three are the stale migration/test/guard variant. A textual conflict resolution that retained this head's numbering could duplicate or skip a production migration. Remedy: close this recovered duplicate, or rebase and deduplicate it while preserving current main migrations 0022/0023 and IdP multi-grant as 0024, then review the new head.

I found no separate reachable P0/P1 tenant-isolation bypass, token-validation allow path, secret exposure, or session-integrity defect in the isolated feature diff. The blocking verdict is the concrete required merge/migration gate failure above.

Non-blocking follow-ups

  • P2: the new operator grant/revoke/restore path changes durable IdP mappings but does not emit a durable mapping-lifecycle audit record; the accepted ADR calls these mapping changes auditable. Request-time IdP allow/deny logs do not preserve who performed the mapping mutation. This is follow-up hardening and does not change the NO_GO basis.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant