Skip to content

Commit 2e35765

Browse files
fix(plugin-sharing): the share-link admission seam derives the tenancy posture — an ex-member's org-stamped API key no longer mints links into the organization it left (#15996)
* fix(plugin-sharing): derive the tenancy posture at the share-link admission seam `resolveAuthzContext` gates every posture-conditional refusal on a posture its caller supplies; this door supplied none, so an API key stamped with an organization its owner has left was admitted carrying that organization as its tenant. Classified per #13906 decision 1 option A: never registered stays quiet, every other rejection becomes AuthzStoreUnavailableError (503). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(plugin-sharing): the share-link admission matrix, its permanent wiring ablation and the 503 outage arm Eight sections over the real booted plugin: controls in both directions, the ex-member and organization-less API keys under `isolated`, the ablation that removes the `tenancy` service and brings the leak back, the registered-and-broken outage (503, never a quiet undefined), the #15409 session arm, `group` measured rather than assumed, and the seam testkit now able to express a posture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(plugin-sharing): contract the harness slot lookups, re-anchor the system-context census check:slot-lookup reddened on the new test's `const ctx: any` host double — the one shape that gate exists to keep contracted. The double is now typed and the cast narrowed to the single hand-over point. check:system-context-census re-anchored one page citation the seam edit moved (line rot, --fix). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1842a2a commit 2e35765

5 files changed

Lines changed: 818 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
The share-link REST surface now derives the tenancy posture before it resolves the caller, so an API key stamped with an organization its owner has left can no longer mint links into that organization.
6+
7+
`resolveAuthzContext` gates every posture-conditional refusal on a `tenancyPosture` its **caller** supplies. `SharingServicePlugin`'s share-link door supplied none, so none of them ran: `organization_required` (`core/security/api-key.ts`), `organization_membership_ended` (`core/security/resolve-authz-context.ts`), and the session arm beside it that drops an `activeOrganizationId` claim no `sys_member` row backs. An API key's tenant is `sys_api_key.active_organization_id` copied verbatim — the caller's own stored claim, never vetted against current membership — so under a wall-enforcing posture (`isolated`, `group`) a key belonging to an ex-member was admitted carrying that organization, and `createLink` minted a capability token on a record inside it. The same door carried the session half: a browser session whose owner had been removed kept its organization claim until the session expired.
8+
9+
Measured at the door, under `isolated`: the ex-member's key went from `200` / `201` with the link landing in the store to `401` / `401` with nothing landing; an organization-less key went from admitted to `401`; an ex-member's *session* now has its stale claim dropped and is refused by Layer 0 at `403` while staying signed in. A current member and an anonymous caller are unchanged in every wiring.
10+
11+
A `tenancy` service that was **never registered** stays a supported composition and resolves quietly to "no posture" — behaviour on an embedding without `plugin-auth` is exactly what it was. A `tenancy` service that **was registered and failed to build** now raises `AuthzStoreUnavailableError`, which reaches the wire as `SERVICE_UNAVAILABLE` / 503 rather than being laundered into a `401`: admission was never decided, so it must not be answered.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ The largest single consumer — **17 of the 105 sites**.
134134
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1238` |
135135
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
137-
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
137+
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` |
138138
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` |
139139
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
140140
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` |

packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import { resolveAuthzContext } from '@objectstack/core';
3131
import type { ExecutionContext } from '@objectstack/spec/kernel';
32+
import type { TenancyPosture } from '@objectstack/spec/security';
3233

3334
/** A `sys_member` row as the identity tables really store it. */
3435
export interface SeamMembership {
@@ -67,6 +68,32 @@ function makeSeamQl(tables: Record<string, any[]>) {
6768
};
6869
}
6970

71+
/**
72+
* What the DEPLOYMENT holds, as opposed to what the principal holds.
73+
*
74+
* [#15349] Separate from {@link SeamPrincipal} on purpose: the tenancy posture
75+
* is a property of the deployment (the `tenancy` service a host registers),
76+
* never of the caller, and a test that reproduced it as a principal field would
77+
* be re-inventing at this seam the hand-built shape the file exists to refuse.
78+
*/
79+
export interface SeamDeployment {
80+
/**
81+
* The posture IN FORCE, exactly as a transport derives it from the `tenancy`
82+
* service and hands it to `resolveAuthzContext`. Omitted — the default — is
83+
* the honest reproduction of a host that registers no `tenancy` service at
84+
* all: `undefined` means "run no posture-conditional refusal", which is a
85+
* different fact from `'single'` (a posture that is present and enforces no
86+
* wall). Every existing caller therefore keeps a byte-identical envelope.
87+
*
88+
* Supply it to reach the refusals the resolver gates on it — the two API-key
89+
* ones and the session arm that drops an `activeOrganizationId` claim no
90+
* `sys_member` row backs. ⛔ A test cannot reach any of them by naming a
91+
* context field itself: the posture is an INPUT to the resolver, and the
92+
* whole point of this kit is that the test never writes the output.
93+
*/
94+
tenancyPosture?: TenancyPosture;
95+
}
96+
7097
/**
7198
* Resolve an execution context the way an inbound HTTP request does.
7299
*
@@ -75,7 +102,10 @@ function makeSeamQl(tables: Record<string, any[]>) {
75102
* `isSystem: false`) — this helper never names a tenancy field, so neither does
76103
* the test that calls it.
77104
*/
78-
export async function bootRequestContext(principal: SeamPrincipal): Promise<ExecutionContext> {
105+
export async function bootRequestContext(
106+
principal: SeamPrincipal,
107+
deployment: SeamDeployment = {},
108+
): Promise<ExecutionContext> {
79109
const activeOrg = principal.activeOrganizationId ?? null;
80110
const memberships: SeamMembership[] =
81111
principal.memberships ?? (activeOrg ? [{ organization_id: activeOrg, role: 'member' }] : []);
@@ -96,6 +126,10 @@ export async function bootRequestContext(principal: SeamPrincipal): Promise<Exec
96126
const authz = await resolveAuthzContext({
97127
ql,
98128
headers: new Headers(),
129+
// [#15349] The posture a transport supplies. Threaded rather than omitted
130+
// so this kit can reproduce a posture-conditional verdict at all; absent by
131+
// default, which is what a `tenancy`-less host really produces.
132+
tenancyPosture: deployment.tenancyPosture,
99133
// The better-auth session shape, as `AuthManager` hands it to the resolver.
100134
getSession: async () => ({
101135
user: { id: principal.userId, email: principal.email },

0 commit comments

Comments
 (0)