rescue: unpushed work from station01 worktree (idp-fed-harden-work) - #205
rescue: unpushed work from station01 worktree (idp-fed-harden-work)#205andrei-hasna wants to merge 10 commits into
Conversation
…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.
|
[REVIEW] NO_GO — #205 @ 7ae4de2 — lens: correctness+security+gates, reviewer unresolved-account006 (1 of 1) What I ran
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
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
|
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 wasverified 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 --remotesstill 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.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.