Skip to content

M1 acceptance gate: persisted client_credentials client gets a token (#25) - #113

Merged
stephane-segning merged 1 commit into
mainfrom
claude/25-m1-acceptance-gate
Aug 3, 2026
Merged

M1 acceptance gate: persisted client_credentials client gets a token (#25)#113
stephane-segning merged 1 commit into
mainfrom
claude/25-m1-acceptance-gate

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

Summary

  • app/sms-gateway/tests/m1_acceptance_gate_live_postgres.rsGate: a persisted client_credentials client gets a token #25's own acceptance gate: provisions a service account through the real provision_app_client procedure, kills the OS process that observed the provisioning, spawns a genuinely fresh one, and proves a real private_key_jwt token exchange plus a real protected route call both work against it. Separately proves a hand-signed, test-only developer-perms token is refused on PATCH /providers/{id}.
  • app/sms-gateway/Cargo.toml — new [dev-dependencies] (base64, chrono, jsonwebtoken, rand, reqwest, rsa, serde, serde_json) this test needs; all already pinned versions used elsewhere in the workspace, no new dependency trees.

Intent

Closes #25 ("Gate: a persisted client_credentials client gets a token"), the closing ticket of epic #18 (M1 — sms-auth). Closes #18: its own "Stories" checklist is #19#25, and #19/#20/#21/#22/#23/#24 are all already closed (#23 as #111, #24 as #112) — #25 was the last one open, and the epic's stated blocker, #87, closed earlier.

Source of truth: #25 (quoting its own text): "Provision a service account through the real provisioning path so it is persisted, restart the process so nothing is served from memory, then exchange client_id + [credential] for a token and call a route with it. An in-memory-only test passes with the GrantType bug present and is therefore worthless here. Second half: assert a developer-role token is refused on a provider:write route."

Scope

  • New live test, ignored by default (same convention as this workspace's other live suites), that:
    1. Rotates in a real OP signing key and seeds the fixtures sms-gateway serve itself requires to start (an active OauthSigningKey; a Provider row keyed orange_cm, resolve_provider_row_id's own requirement) plus what sendMessage needs downstream (an approved SenderId).
    2. Spawns sms-gateway serve as process Add milestone 0 crates: sms-encoding and sms-msisdn #1 via env!("CARGO_BIN_EXE_sms-gateway") (only available to an integration test inside the sms-gateway package itself, which is why this test lives here rather than alongside this workspace's other live suites in sms-api/sms-auth).
    3. Provisions a service account through the real Procedures::provision_app_client — real RSA keygen, real transactional Postgres writes (AppClient + OauthClient), against the same live database process Add milestone 0 crates: sms-encoding and sms-msisdn #1 is itself serving from.
    4. Kills process Add milestone 0 crates: sms-encoding and sms-msisdn #1 for realChild::kill() (SIGKILL on Unix, not a graceful shutdown) then Child::wait(), blocking until the OS has genuinely reaped it.
    5. Spawns sms-gateway serve again as process Fix mermaid diagrams CI job: pass --no-sandbox to mmdc #2 — a different PID, zero shared memory with process Add milestone 0 crates: sms-encoding and sms-msisdn #1, same database, same port.
    6. Signs a real private_key_jwt client assertion with the persisted private key, exchanges it at process Fix mermaid diagrams CI job: pass --no-sandbox to mmdc #2's own /token, and calls sendMessage with the resulting access token — succeeds only because the client row is actually in Postgres.
    7. Hand-signs a test-only token carrying the developer role's real perms (§5.2 of the design doc: app:read, webhook:manage, message:read, message:send — no provider:* permission), signed with the same active OP signing key so GatewayAuth's real RS256/JWKS validation accepts it structurally, and asserts PATCH /providers/{id} returns 403 naming provider:update.
  • One deliberate, documented deviation from the ticket's literal wording (spelled out in the test file's own module doc): step 3 calls Procedures::provision_app_client directly rather than making an HTTP request to process Add milestone 0 crates: sms-encoding and sms-msisdn #1. provisionAppClient's own @allow in schema.cstack is hasRole('owner') || hasRole('admin'), and GatewayAuth::authenticate unconditionally sets role: "app" for every real client_credentials token this deployment ever issues — there is no human-login path to obtain an owner/admin-role token from at all. rbac_layer2_live_postgres.rs's own module doc already reaches the identical conclusion for PATCH /providers/{id}, and provision_app_client_live_postgres.rs already made the same choice for the same reason. What the ticket's own stated purpose actually needs — persistence surviving a real process boundary, and a real token exchange plus a real protected call against the restarted process — is still exercised for real over HTTP, against a process that never observed the provisioning call.
  • Similarly, step 7's token is explicitly a stand-in, not a real issuance path: no flow in this deployment ever mints a perms-bearing token (sms_auth::op never sets one), so there is no real OAuth flow to obtain a developer-shaped token from either.

Verification

just check   # OK
just lint    # OK (cargo fmt --check + clippy -D warnings, workspace-wide)
just test    # OK — new test correctly shows as `ignored` outside a live run
./ci/assert-no-raw-sqlx.sh   # R1 OK (scanned: crates app)
just parity  # OK, unaffected — no schema/state-machine changes

Then the actual gate, live, twice in a row against a fresh postgres:16 container (migrated with ci/apply-migrations.sh):

DATABASE_URL=postgres://postgres:postgres@localhost:<port>/vsms_check \
    cargo test -p sms-gateway --test m1_acceptance_gate_live_postgres -- --ignored --nocapture

Both runs passed. Confirmed from the test's own PID/exit-status logging that it genuinely spawns and kills two separate OS processes, not two logical phases in one:

spawned sms-gateway serve, pid 83336, issuer http://127.0.0.1:60065
killed  sms-gateway serve, pid 83336, exit status ExitStatus(unix_wait_status(9))
spawned sms-gateway serve, pid 83346, issuer http://127.0.0.1:60065
... sendMessage succeeds against pid 83346 ...
killed  sms-gateway serve, pid 83346, exit status ExitStatus(unix_wait_status(9))

unix_wait_status(9) on both is SIGKILL, confirming kill_and_wait genuinely hard-kills rather than requesting a graceful shutdown. A second full run reused the idempotent orange_cm Provider fixture cleanly and passed identically, confirming the test is safe to re-run against a non-reset database (this workspace's existing live-suite convention).

Screenshots/Evidence

N/A — a backend integration test with no UI surface. The PID/exit-status transcript above is the evidence that matters here (the two-process requirement), reproduced in full in this PR's own description above rather than a screenshot.

Risk Assessment

Low. Additive only: one new #[ignore]d test file (never runs in a normal cargo test/CI unit-test pass, only under an explicit -- --ignored live run against a real Postgres) and new [dev-dependencies] entries scoped to app/sms-gateway's own test target. No production code path changes — app/sms-gateway/src/main.rs, op.rs, and every crates/ file are untouched.

AI Usage Declaration

This PR was implemented by Claude Code (Claude Sonnet 5) under the repo owner's direction, working from the ticket's own text (issue #25) plus the epic's accumulated findings (#18 and its linked PRs).

  • I reviewed the test file and understand what it does and why each fixture/step exists.
  • I ran the live gate myself against a real, disposable postgres:16 Docker container (not simulated, not assumed) — twice in a row — and confirmed both runs pass, including reading the PID/exit-status output to confirm two distinct OS processes were genuinely spawned and SIGKILLed, not approximated within one process.
  • I am being explicit and honest that (a) the hand-signed developer-perms token in step 7 is a deliberate stand-in for a future human-login path, not a real issuance route, and (b) provisioning in step 3 is a direct procedure call rather than a literal HTTP request to process Add milestone 0 crates: sms-encoding and sms-msisdn #1, for the documented Layer-1-policy reason above — this is a correction to the ticket's own wording, not a silent weakening of what it asks for.

Reviewer Focus

  • Whether the "direct Procedures::provision_app_client call instead of HTTP to process Add milestone 0 crates: sms-encoding and sms-msisdn #1" deviation is an acceptable reading of Gate: a persisted client_credentials client gets a token #25's intent, given no owner/admin-role token is reachable over real HTTP in this deployment today (same conclusion rbac_layer2_live_postgres.rs already documented for the RBAC negative case).
  • Whether sign_developer_stand_in_token's framing (hand-signed, explicitly not a real issuance path) is honest and clear enough, or should be hardened further (e.g. an even louder in-code warning) given it's the one piece of this test that doesn't go through a real OP flow.

…urvives a process restart

Closes #25. Closes #18.

Two OS processes, not two logical phases in one: `app/sms-gateway/tests/m1_acceptance_gate_live_postgres.rs`
spawns the real `sms-gateway serve` binary via `CARGO_BIN_EXE_sms-gateway`,
provisions a service account through the real `provision_app_client`
procedure against the same live Postgres, SIGKILLs and reaps that process,
then spawns a genuinely fresh one and proves a real private_key_jwt token
exchange plus a real sendMessage call both work against it — the only way
to actually falsify the GrantType bug #25 exists to guard against, per its
own text ("An in-memory-only test passes with the GrantType bug present
and is therefore worthless here").

Separately asserts a hand-signed, test-only token carrying the developer
role's real perms (no provider:* permission) is refused on PATCH
/providers/{id}, naming provider:update — the RBAC half of the gate.

Deviates from the ticket's literal wording in one place, documented in the
test file's own module doc: provisioning is a direct
Procedures::provision_app_client call rather than an HTTP request to
process #1, because provisionAppClient's own @Allow requires
hasRole('owner')/hasRole('admin') and GatewayAuth never mints either role
for a real token in this deployment (no human-login path exists yet) —
the same conclusion rbac_layer2_live_postgres.rs already reached for
PATCH /providers/{id}. What the gate actually cares about — persistence
surviving a real process boundary, and a real token exchange plus a real
protected call against the restarted process — is still exercised for
real over HTTP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 58c041d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@stephane-segning
stephane-segning merged commit dbb8546 into main Aug 3, 2026
5 checks passed
stephane-segning added a commit that referenced this pull request Aug 3, 2026
The doc still read "M1 is still open: #23, #24, #25" after all three
merged and closed epic #18. Replaced the stale line with a writeup
matching the file's existing density: what each PR actually built, the
client-secret-vs-private_key_jwt schema conflict caught and resolved in
permission-literal bug Lightbridge caught in #24, and #25's two-genuinely-
separate-OS-processes acceptance gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
stephane-segning added a commit that referenced this pull request Aug 3, 2026
…) (#115)

The doc still read "M1 is still open: #23, #24, #25" after all three
merged and closed epic #18. Replaced the stale line with a writeup
matching the file's existing density: what each PR actually built, the
client-secret-vs-private_key_jwt schema conflict caught and resolved in
permission-literal bug Lightbridge caught in #24, and #25's two-genuinely-
separate-OS-processes acceptance gate.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.

Gate: a persisted client_credentials client gets a token Epic: M1 — sms-auth: OIDC, service accounts, RBAC

1 participant