i3x: bind subscription ownership to the authenticated principal - #711
i3x: bind subscription ownership to the authenticated principal#711AlexGodbehere wants to merge 2 commits into
Conversation
Subscription ownership was enforced by comparing the clientId in the request body against the clientId stored on the subscription. Both sides came from the client, so anyone who learned a subscriptionId could claim the matching clientId and read the queued values, attach to the SSE stream, or delete the subscription. Subscriptions now record the authenticated principal (req.auth, set by FplusHttpAuth) as their owner, and every access is checked against it. clientId is still stored and echoed because it is part of the i3X wire shape, but it no longer protects anything. A subscription owned by another principal now reports 404, identically to one that does not exist, so the pair cannot be used to probe which subscription ids are live. acs-directory does the same for alerts, for the same reason. Nothing in the subscription routes returns 403 any more.
Adds an ownership block to the SubscriptionManager tests covering clientId spoofing, foreign-subscription 404 parity with unknown ids, the absence of any 403, two principals sharing a clientId, one principal using two clientIds, and the full single-principal lifecycle. The route tests now install a stand-in for FplusHttpAuth so req.auth is set, and assert that handlers pass the principal rather than the body clientId to the SubscriptionManager.
Review: i3X subscription ownership is now the authenticated principal, not a client-supplied stringBranch: What changed (functional level)
Subscriptions now carry an Worked example. Alice creates a subscription as principal WalkthroughOwnership
404 rather than 403
Unauthenticated requests
Tests
How to test
Decisions / open questions
Files of noteImplementation:
Tests:
Cited but not touched:
|
Binds i3X subscription ownership to the authenticated Factory+ principal instead of the client-supplied
clientId.This is a behavioural change to existing endpoints. The subscription routes can no longer return 403, and a subscription belonging to another principal now returns 404 where it previously returned 403. See Compatibility below.
The problem
SubscriptionManager.getAndVerifyenforced ownership like this:Both
subscriptionIdandclientIdarrive from the client —clientIdin the request body,subscriptionIdin the body or query. The check compares one client-supplied string against another, so it proves nothing.The takeover scenario in plain terms. Alice creates a subscription and registers half a dozen production assets against it. Her
clientIdandsubscriptionIdare visible in her browser's network tab, in any ingress or proxy log that captures request bodies, and in anything that mirrors HTTP traffic. Mallory — who needs credentials good enough to authenticate to the i3X service at all, and no right whatsoever to Alice's data — sends{"clientId": "<Alice's clientId>", "subscriptionId": "<Alice's subscriptionId>"}to:/v1/subscriptions/sync, and reads every value Alice has queued;/v1/subscriptions/stream, and attaches to her live SSE feed;/v1/subscriptions/delete, and destroys it;/v1/subscriptions/register, and adds elements to it, or/unregisterand silently blinds her monitor.Nothing in
acs-i3xreadreq.authat all before this change (grep -rn "req.auth" acs-i3x/libreturned nothing), even though the sharedFplusHttpAuthmiddleware inlib/js-service-api/lib/auth.jssets it on every route except the public/v1/info.There was a second, smaller problem in the same three lines. An unknown id returned 404; a known id owned by someone else returned 403. That pair is an existence oracle: any authenticated caller could walk ids and learn which subscriptions are live, without ever being able to read one.
The fix
Subscriptiongains anownerfield, set at creation time fromreq.auth. It is never supplied by the client and never appears on the wire.getAndVerifycomparesowneragainst the caller's principal.clientIdis no longer consulted for access control.clientIdis still stored on the subscription and still echoed in responses, because it is part of the i3X spec shape and clients send it. It just stops being the thing that protects anything.req.authinto theSubscriptionManagerthrough a smallsubscription_owner(req)helper inapi-v1.ts. All seven call sites were changed:create,list,delete,register,unregister,sync,stream.404, not 403, for a foreign subscription
A subscription owned by another principal is now reported exactly as one that does not exist: status 404, message
Subscription <id> not found. The message no longer names the owner and no longer says "does not belong to". A caller cannot tell "wrong owner" from "no such id", which closes the oracle.There is precedent for this in the codebase, and it was deliberate there too.
acs-directory/lib/api_v1.js, around lines 292-295:Same reasoning, same shape.
What
owneris whenreq.authis nullsubscription_ownerreturnsreq.authas-is, which is undefined only if a request somehow reaches a subscription route without passingFplusHttpAuth. That should be impossible:bin/api.tsmarks only/v1/infopublic, and/v1/infonever touches subscriptions. Rather than rely on that,getAndVerifytreats a falsy owner as never matching — it fails closed to 404 instead of matching a subscription that happened to be stored with a falsy owner.list()does the same. This is defence in depth on a path that should not exist, and it deliberately introduces no new error status.Compatibility
Both known consumers were checked by reading their code.
acs-admin — transparent, no change needed.
acs-admin/src/store/useMonitorStore.jsgenerates a fresh randomclientIdper store instance (acs-admin-<uuid>) and holdssubscriptionIdin the same store, set only from thecreateSubscriptioncall it just made. The store is not persisted, so it never carries asubscriptionIdacross a page load and never tries to reattach to a subscription it did not create.client.Fetch.fetch, or a service token resolved by the same service-client for SSE (useI3xClient.js,useI3xSSE.js), so the principal is the logged-in user. One session equals one principal equals one clientId, and ownership-by-principal is a strict superset of what the clientId check allowed.clientIdand its ownsubscriptionId. They never touch each other's. Covered by the test "lets one principal use two different clientIds"._startStreamonly runs whenthis.subscriptionIdis already set from this session, so there is no reattach-with-a-new-clientId path to break. On reload the store starts empty and creates a fresh subscription; the orphan expires on the server TTL.request()throws a plainErroron any non-ok response, and the store's cleanup paths.catch(() => {}). The 403-to-404 change is invisible to it.External Rust consumer (the nostromo bridge, separate private repo) — this change is what it needs.
clientIdofnostromo-bridgewith a single credential. One principal, one clientId, so its own subscriptions always match on owner.grep -rn "403" acs-i3x/lib/ acs-i3x/bin/now returns nothing at all, anywhere in the service. The only status the subscription routes produce for a missing-or-foreign subscription is 404, with the plain "not found" message the bridge already handles. TTL expiry, deletion by the owner, and a foreign id are now indistinguishable to it, which is exactly what its state machine assumes.How to test
cd acs-i3x && npm install && npm test— 13 suites, 349 tests, all passing.POST /v1/subscriptionswith{"clientId": "alice-client", "displayName": "test"}. Note the returnedsubscriptionId.POST /v1/subscriptions/registerwith thatsubscriptionIdand a realelementId, thenPOST /v1/subscriptions/sync. You get values.clientIdandsubscriptionId:POST /v1/subscriptions/sync. Expect HTTP 404,Subscription <id> not found. Before this change it returned A's data.POST /v1/subscriptions/syncwith a random UUID assubscriptionId. Expect a response identical in shape and status to step 3, differing only in the id.POST /v1/subscriptions/deletewith A'ssubscriptionId. Expect a per-id 404 inside the envelope, then confirm as A that the subscription still works./v1/subscriptions/streamand/v1/subscriptions/register. All 404, none 403.Tests added
In
test/subscriptions.test.ts, a newownershipblock:subscriptionIdand theclientIdgets 404 from all eight manager entry points, and the subscription is intact afterwards;clientIdstay separated;clientIds works, which is the acs-admin two-tab case;The existing "throws 403 for wrong clientId" cases were retitled and now assert 404.
In
test/api-v1.test.tsandtest/e2e.test.ts, the test apps install a stand-in forFplusHttpAuththat setsreq.auth, and the route tests assert the handlers pass the principal rather than the bodyclientIdto the manager.e2e.test.tsdeliberately uses a principal string that differs from theclientIdthe requests carry, so a regression that reverted to the body value would fail rather than coincidentally pass.Test results
Four consecutive full runs, all clean. Nothing unrelated is failing on this branch.
Known pre-existing flake, for whoever reviews:
test/e2e.test.tsintermittently fails, roughly one run in four, with a response body arriving empty on one of severalsupertestrequests fired in parallel. It reproduces on unmodifiedmainand was hit by the sibling work on #709. I did not see it in four runs here, and it is unrelated to this change — if CI trips it, it is not this PR.Noticed, not fixed
acs-i3x/docs/to-improve.mdrow D7 records the old intent, "clientIdmismatch should be 403", as Resolved. That row is now historically inaccurate. Left alone because docs hygiene in this service is being handled separately.acs-i3x/docs/2026-04-01-acs-i3x.mdline 526 listsAPIError(403) → 403 with error envelopein the error mapping. Still true generically, but no subscription route emits 403 any more.SubscriptionManagerlogs with bareconsole.lograther than a bound debug logger, and those lines include element ids and values. Out of scope; logging hygiene is in flight elsewhere.SubscriptionManager.streamthrows a plainErrorwith no.statuswhen a subscription already has an active stream, so the envelope will surface it as a 500. Arguably should be a 409. Left alone.Release notes
i3X subscriptions are now owned by the authenticated Factory+ principal rather than by the
clientIdin the request body. Previously, any caller who could authenticate to the i3X service and who learned another client'ssubscriptionIdandclientId— both of which travel in plain request bodies — could read that subscription's queued values, attach to its live SSE stream, register or unregister elements on it, or delete it outright. Ownership is now checked against the verified principal, which a client cannot choose.clientIdis unchanged on the wire. Clients still send it, it is still stored and echoed back, and nothing needs to change in what any client sends.One behavioural change to be aware of: requesting a subscription that belongs to a different principal now returns 404 Not Found where it previously returned 403 Forbidden. This is deliberate. It makes a foreign subscription indistinguishable from one that does not exist, so the response cannot be used to discover which subscription ids are live, and it matches what acs-directory already does for alerts. Any client that treats 404 as "my subscription is gone, create a new one" will self-heal correctly; a client that specifically watched for 403 on these routes will no longer see it.