Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/share-link-admission-tenancy-posture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/plugin-sharing": patch
---

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.

`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.

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.

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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ The largest single consumer — **17 of the 105 sites**.
| 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` |
| 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`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` |
| 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` |
| 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` |
| 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` |
Expand Down
36 changes: 35 additions & 1 deletion packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

import { resolveAuthzContext } from '@objectstack/core';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { TenancyPosture } from '@objectstack/spec/security';

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

/**
* What the DEPLOYMENT holds, as opposed to what the principal holds.
*
* [#15349] Separate from {@link SeamPrincipal} on purpose: the tenancy posture
* is a property of the deployment (the `tenancy` service a host registers),
* never of the caller, and a test that reproduced it as a principal field would
* be re-inventing at this seam the hand-built shape the file exists to refuse.
*/
export interface SeamDeployment {
/**
* The posture IN FORCE, exactly as a transport derives it from the `tenancy`
* service and hands it to `resolveAuthzContext`. Omitted — the default — is
* the honest reproduction of a host that registers no `tenancy` service at
* all: `undefined` means "run no posture-conditional refusal", which is a
* different fact from `'single'` (a posture that is present and enforces no
* wall). Every existing caller therefore keeps a byte-identical envelope.
*
* Supply it to reach the refusals the resolver gates on it — the two API-key
* ones and the session arm that drops an `activeOrganizationId` claim no
* `sys_member` row backs. ⛔ A test cannot reach any of them by naming a
* context field itself: the posture is an INPUT to the resolver, and the
* whole point of this kit is that the test never writes the output.
*/
tenancyPosture?: TenancyPosture;
}

/**
* Resolve an execution context the way an inbound HTTP request does.
*
Expand All @@ -75,7 +102,10 @@ function makeSeamQl(tables: Record<string, any[]>) {
* `isSystem: false`) — this helper never names a tenancy field, so neither does
* the test that calls it.
*/
export async function bootRequestContext(principal: SeamPrincipal): Promise<ExecutionContext> {
export async function bootRequestContext(
principal: SeamPrincipal,
deployment: SeamDeployment = {},
): Promise<ExecutionContext> {
const activeOrg = principal.activeOrganizationId ?? null;
const memberships: SeamMembership[] =
principal.memberships ?? (activeOrg ? [{ organization_id: activeOrg, role: 'member' }] : []);
Expand All @@ -96,6 +126,10 @@ export async function bootRequestContext(principal: SeamPrincipal): Promise<Exec
const authz = await resolveAuthzContext({
ql,
headers: new Headers(),
// [#15349] The posture a transport supplies. Threaded rather than omitted
// so this kit can reproduce a posture-conditional verdict at all; absent by
// default, which is what a `tenancy`-less host really produces.
tenancyPosture: deployment.tenancyPosture,
// The better-auth session shape, as `AuthManager` hands it to the resolver.
getSession: async () => ({
user: { id: principal.userId, email: principal.email },
Expand Down
Loading
Loading