diff --git a/.changeset/share-link-redactfields-survive-optout.md b/.changeset/share-link-redactfields-survive-optout.md new file mode 100644 index 0000000000..86f7699043 --- /dev/null +++ b/.changeset/share-link-redactfields-survive-optout.md @@ -0,0 +1,30 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +fix(plugin-sharing): an object's declared `publicSharing.redactFields` keep applying to share-link redemption after the object opts out (#13856) + +`ShareLinkService.getPolicy()` collapsed to an EMPTY policy whenever the +object's `publicSharing` block had `enabled !== true` — `redactFields: []` +included. A link minted while the object was opted IN and redeemed after it +was opted OUT therefore kept resolving and started serving the very fields the +object declares redacted: turning the feature OFF made the anonymous endpoint +serve MORE data than it did while the feature was ON. Fail-open in the wrong +direction, and wrong under either answer to the standing-policy question. + +The declared redaction set is now read from the object's declared +`publicSharing` block regardless of `enabled`, so opting out can never widen +what an existing token serves. Anonymous redemptions on opted-out objects that +previously received the declared-redacted fields stop receiving them — that +narrowing is this fix's intent, declared here rather than smoothed over. + +Unchanged, deliberately: + +- the `enabled: true` path (declared ∪ per-link union, byte-identical); +- an object with no `publicSharing` block at all (no redaction set sprouts); +- the per-link `redact_fields` half of the union; +- the mint-time opt-in gate (`SHARING_NOT_ENABLED`, 422) and the #13608 + redemption-time eligibility gate; +- whether an already-minted link should still RESOLVE at all once + `enabled` is false — that ruling is pending in #14033 and is not + implemented here in either direction. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index fa4e087fe9..b6532671fd 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**. | 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:1286` (guard at `:1311`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:434`, `:488`, `:492`, `:565`, `:595` | | 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:157`, `:382` | diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index b909a7ccb7..e7ec7a57eb 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -453,3 +453,157 @@ describe('ShareLinkService', () => { }); }); }); + +// ── [#13856] declared `redactFields` survive the object opting OUT ────────── +// +// `getPolicy()` used to collapse to an EMPTY policy whenever +// `publicSharing.enabled !== true` — `redactFields: []` included. So a link +// minted while the object was opted IN, redeemed after it was opted OUT, kept +// resolving and started serving MORE fields than it did while the feature was +// on: turning the switch OFF widened what the anonymous endpoint serves. +// Fail-open, and wrong under either answer to the standing-policy question: +// the declared redaction set is now read from the declared block regardless +// of `enabled`, so opting out can never widen what an existing token serves. +// +// ⛔ Deliberately NOT asserted here: whether the link should resolve AT ALL +// once `enabled` is false. That is the deployment-visible ruling pending in +// #14033. These tests take today's behaviour (it resolves) as a given and pin +// only the redaction set served when it does; if #14033 rules that de-opt-in +// kills standing links, the resolve-side readings here move with that card. +describe('[#13856] declared redactFields survive publicSharing opt-out', () => { + /** An opt-in object whose schema object is LOCAL to the test, so `enabled` can be flipped. */ + function makeOptOutHarness() { + const schemas: Record = { + sys_share_link: { name: 'sys_share_link', fields: {} }, + articles: { + name: 'articles', + publicSharing: { enabled: true, redactFields: ['owner_id', 'cost'] }, + fields: { id: {}, title: {}, body: {}, owner_id: {}, cost: {} }, + }, + // Reverse control: never declared a publicSharing block at all. + plain_notes: { name: 'plain_notes', fields: { id: {}, text: {}, secret: {} } }, + }; + const engine = makeFakeEngine(schemas); + engine._tables.articles = [{ id: 'a1', title: 'T', body: 'B', owner_id: 'u9', cost: 42 }]; + engine._tables.plain_notes = [{ id: 'n1', text: 'hi', secret: 's3' }]; + const service = new ShareLinkService({ engine: engine as any }); + return { schemas, engine, service }; + } + + /** Seed a pre-existing link directly (mint refuses for these paths — that gate is pinned below). */ + function seedLink(engine: any, row: Record) { + engine._tables.sys_share_link = [{ + id: 'shl_seeded', + permission: 'view', + audience: 'link_only', + expires_at: null, + email_allowlist: null, + password_hash: null, + redact_fields: null, + revoked_at: null, + use_count: 0, + ...row, + }]; + } + + it('THE REPRO — opting out keeps the declared redactions applying', async () => { + const { schemas, service } = makeOptOutHarness(); + const link = await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, + { userId: 'u1' }, + ); + + const on = await service.resolveToken(link.token); + expect(on).not.toBeNull(); + + schemas.articles.publicSharing.enabled = false; + + // Today's behaviour, under ruling in #14033 — a given here, not a pin. + const off = await service.resolveToken(link.token); + expect(off).not.toBeNull(); + + // Positive: the declared fields are still stripped after opt-out. + expect(off!.redactFields).toContain('owner_id'); + expect(off!.redactFields).toContain('cost'); + + // ⭐ The directional assertion — the field set served with the switch OFF + // is a SUBSET of the set served with it ON. Opting out may only ever + // narrow what an existing token serves, never widen it. + const allFields = Object.keys(schemas.articles.fields); + const servedOn = allFields.filter((f) => !on!.redactFields.includes(f)); + const servedOff = allFields.filter((f) => !off!.redactFields.includes(f)); + expect( + servedOff.filter((f) => !servedOn.includes(f)), + 'fields served ONLY after opting out — must be none', + ).toEqual([]); + }); + + it('boundary — the per-link redact_fields union is unchanged when the switch is off', async () => { + const { schemas, service } = makeOptOutHarness(); + const link = await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, + { userId: 'u1' }, + ); + schemas.articles.publicSharing.enabled = false; + + const off = await service.resolveToken(link.token); + expect(off).not.toBeNull(); + // Exactly declared ∪ per-link — nothing dropped, nothing sprouted. + expect(new Set(off!.redactFields)).toEqual(new Set(['owner_id', 'cost', 'body'])); + }); + + it('control — the enabled:true path serves exactly declared ∪ per-link, as before', async () => { + const { service } = makeOptOutHarness(); + const link = await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, + { userId: 'u1' }, + ); + const on = await service.resolveToken(link.token); + expect(on).not.toBeNull(); + expect(new Set(on!.redactFields)).toEqual(new Set(['owner_id', 'cost', 'body'])); + }); + + it('reverse control — an object with no publicSharing block sprouts NO redaction set', async () => { + const { engine, service } = makeOptOutHarness(); + seedLink(engine, { + token: 'noblock-token-1234567890', + object_name: 'plain_notes', + record_id: 'n1', + }); + const resolved = await service.resolveToken('noblock-token-1234567890'); + expect(resolved).not.toBeNull(); + expect(resolved!.redactFields).toEqual([]); + }); + + it('reverse control — per-link redactions still apply alone on a block-less object', async () => { + const { engine, service } = makeOptOutHarness(); + seedLink(engine, { + token: 'noblock-token-0987654321', + object_name: 'plain_notes', + record_id: 'n1', + redact_fields: ['secret'], + }); + const resolved = await service.resolveToken('noblock-token-0987654321'); + expect(resolved).not.toBeNull(); + expect(resolved!.redactFields).toEqual(['secret']); + }); + + // The rejection assertion, per the ADR-0112 envelope: `code` AND `status` — + // a bare `.toThrow()` could pass on a refusal for the wrong reason entirely. + it('the mint-time opt-in gate is untouched — enabled:false still refuses SHARING_NOT_ENABLED', async () => { + const { schemas, service } = makeOptOutHarness(); + schemas.articles.publicSharing.enabled = false; + let caught: any; + try { + await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view' }, + { userId: 'u1' }, + ); + } catch (err) { + caught = err; + } + expect(caught, 'expected a refusal, but the mint resolved').toBeDefined(); + expect(caught.status).toBe(422); + expect(caught.code).toBe('SHARING_NOT_ENABLED'); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 3909d07fea..32a9e69888 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -100,7 +100,18 @@ function getPolicy(schema: any): { enabled: false, allowedAudiences: [], allowedPermissions: [], - redactFields: [], + // [#13856] The declared redaction set is read REGARDLESS of `enabled`. + // This branch used to return `redactFields: []`, so a link minted while + // the object was opted IN and redeemed after it was opted OUT kept + // resolving AND started serving the very fields the object declares + // redacted — turning the feature off WIDENED what the anonymous + // endpoint serves. Opting out gates MINTING (`createLink`'s 422 reads + // `enabled`, not this list) and whatever #14033 rules for standing + // links; it must never strip the object's declared redactions from + // tokens that still serve. An object with no `publicSharing` block at + // all keeps `[]` — nothing declared, nothing redacted — exactly as + // before. + redactFields: Array.isArray(raw?.redactFields) ? (raw.redactFields as string[]) : [], }; } return {