|
| 1 | +--- |
| 2 | +"@objectstack/rest": patch |
| 3 | +"@objectstack/core": patch |
| 4 | +--- |
| 5 | + |
| 6 | +fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 |
| 7 | + |
| 8 | +Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an |
| 9 | +organization its owner is no longer a member of **read and wrote that |
| 10 | +organization's rows** on the wiring the open core actually builds. Not a silent |
| 11 | +empty set — a GET that returned the other organization's records, and a POST |
| 12 | +that landed a row read back from the store carrying that organization's id and |
| 13 | +the ex-member as its creator. An organization-less key on the same deployment |
| 14 | +read `200` with an empty set, which is the silent failure the wall exists to |
| 15 | +replace. |
| 16 | + |
| 17 | +The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the |
| 18 | +effective tenancy posture from a per-request kernel, and on the single-kernel |
| 19 | +wiring there is no per-request kernel — so the posture was `undefined` on every |
| 20 | +request, and both posture-conditional API-key refusals are gated on it: |
| 21 | +`organization_required` in `api-key.ts` and `organization_membership_ended` in |
| 22 | +`resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was |
| 23 | +active the whole time; it compares against the caller's active organization, |
| 24 | +and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim |
| 25 | +— the holder's own stored claim. Enforcing the wall is what let the ex-member |
| 26 | +through, because the one fact that would expose the ended membership was not an |
| 27 | +input to the layer that could act on it. |
| 28 | + |
| 29 | +The single-kernel branch now derives the posture from a provider `rest-api-plugin` |
| 30 | +wires to the lone local kernel's `tenancy` service, in the same shape as the |
| 31 | +auth-service provider beside it. A host that registers no `tenancy` service is |
| 32 | +unchanged and still admits: there is no wall on such a deployment, so there is |
| 33 | +nothing for an organization-less key to be walled out of. A `tenancy` service |
| 34 | +that was registered and **failed to build** is an outage and answers `503`, not |
| 35 | +an admission — a posture that could not be read is not a posture that is absent. |
| 36 | + |
| 37 | +Refusals are now also said out loud on the server side, at `warn`, where each |
| 38 | +one is decided: the key's row id (never the credential or its hash), the |
| 39 | +principal, the organization and the reason. **The wire is unchanged** — both |
| 40 | +refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the |
| 41 | +body, so a holder of someone else's key learns nothing a plain 401 does not |
| 42 | +already tell them. The operator, who previously had a key that was neither |
| 43 | +revoked nor expired and a 401 that said nothing, now has a line to find. |
| 44 | + |
| 45 | +Behaviour that does not move: a current member's key on the same route still |
| 46 | +returns its rows and still writes; a request with no credential still answers |
| 47 | +401; and an unknown, revoked or expired key is not a refusal at all, so a key |
| 48 | +scanner produces no log volume. |
0 commit comments