From d99aee94e41775703ec68b33683e7e3e332b402f Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:34:22 +0200 Subject: [PATCH 1/3] test: Installation deduplication resolves a create onto the record holding the presented deviceToken --- README.md | 2 + spec/ParseInstallation.spec.js | 325 +++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- 5 files changed, 330 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f8b397b910..e3629542c9 100644 --- a/README.md +++ b/README.md @@ -686,6 +686,8 @@ Parse Server deduplicates `_Installation` records when a new install collides wi When `true`, the dedup operation runs with the caller's auth context so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. +Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions — which is what the Parse SDKs create, as an unauthenticated device registration has no principal to grant write access to. To scope the deduplication, combine this option with either an ACL on `_Installation` records or class-level permissions that withhold the `delete` operation from the caller. + #### `duplicateDeviceTokenAction` What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 00f6b2e3a8..abe6eb42d9 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1502,6 +1502,127 @@ describe('Installations', () => { expect(protectedRow.deviceToken).toBe(t); }); + // `duplicateDeviceTokenActionEnforceAuth` runs the dedup action under the caller's + // auth context, so what it enforces is the row ACL and the class-level permissions. + // It is therefore not by itself a switch that stops unauthenticated dedup: a row + // that carries no ACL under permissive class-level permissions is writable by the + // public, so it stays in scope. The two specs below pin both halves of that, so the + // option's documented scope stays measurable. + it('enforceAuth=true leaves conflicting rows that carry no ACL in dedup scope', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-no-acl-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-no-acl-b', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-no-acl-b'); + }); + + it('enforceAuth=true preserves a conflicting row without an ACL when the class-level permissions deny the caller the delete operation', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = randomUUID(); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-clp-existing', + }); + + const schemaResponse = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/_Installation', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: { + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: { '*': true }, + // The only operation withheld from the public. `find` and `delete` are + // blocked for non-master callers at the REST layer regardless, but the + // dedup delete runs below that layer, so the class-level permission is + // what it is checked against once `enforceAuth` is on. + delete: {}, + }, + }), + }); + expect(schemaResponse.status).toBe(200); + + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-clp-other', + }); + + // The dedup delete is rejected and swallowed, so the pre-existing row keeps its + // deviceToken and the new install is inserted alongside it. + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const preExisting = all.find(r => r.installationId === 'iid-clp-existing'); + expect(preExisting).toBeDefined(); + expect(preExisting.deviceToken).toBe(t); + }); + + it('enforceAuth=false dedups a conflicting row even when the class-level permissions deny the caller the delete operation', async () => { + // Control for the spec above: with `enforceAuth` off the dedup runs as master, so + // the class-level permissions are not consulted at all and the same class-level + // permissions that preserve the row above do not preserve it here. The two specs + // together pin that it is the combination that scopes the dedup, not either half. + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: false }); + const t = randomUUID(); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-clp-existing', + }); + + const schemaResponse = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/_Installation', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: { + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: { '*': true }, + delete: {}, + }, + }), + }); + expect(schemaResponse.status).toBe(200); + + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-clp-other', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-clp-other'); + }); + it('enforceAuth=true with master-key caller still bypasses ACL and dedups', async () => { await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); const t = randomUUID(); @@ -1800,6 +1921,210 @@ describe('Installations', () => { }); }); + + describe('deviceToken adoption on create (no installationId presented)', () => { + const { randomUUID } = require('crypto'); + const anonymousHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + // A create that presents a `deviceToken` already held by exactly one row and no + // `installationId` of its own is resolved onto that row rather than inserting a + // second one. This is the documented deduplication contract: see the comment on + // `ios merge existing same token no installation id` above, which describes the + // imported-device-token flow the branch exists for and states that the matched + // row is reused so that fields added out-of-band (channels, custom columns) are + // preserved. The tests below pin that contract and the permission checks that + // still apply to the resulting write. + + it('resolves onto the row holding the deviceToken instead of inserting a second row', async () => { + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-existing', + channels: ['news'], + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: anonymousHeaders, + body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }), + }); + + // The write is applied to the existing row, so the response carries `updatedAt` + // rather than the `objectId`/`createdAt` of a newly inserted row. + expect(response.status).toBe(200); + expect(response.data.updatedAt).toBeDefined(); + expect(response.data.objectId).toBeUndefined(); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].deviceToken).toBe(t); + expect(all[0].installationId).toBe('iid-existing'); + expect(all[0].channels).toEqual(['sports']); + }); + + it('returns a created response when no row holds the deviceToken', async () => { + // Control for the spec above: the `200`/`updatedAt` response there is specific to + // resolving onto an existing row. A deviceToken no row holds is inserted normally, + // so the response shape distinguishes the two outcomes. + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: anonymousHeaders, + body: JSON.stringify({ deviceToken: randomUUID(), deviceType: 'ios' }), + }); + + expect(response.status).toBe(201); + expect(response.data.objectId).toBeDefined(); + expect(response.data.updatedAt).toBeUndefined(); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + }); + + it('deduplicates the row holding the deviceToken when the caller presents its own installationId', async () => { + // With an installationId of its own the caller does not resolve onto the matched + // row; the deduplication configured by `installation.duplicateDeviceTokenAction` + // applies to it instead and the caller's own install is inserted. + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-first', + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: anonymousHeaders, + body: JSON.stringify({ + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-second', + }), + }); + + expect(response.status).toBe(201); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-second'); + }); + + it('rejects the write when the caller presents its installationId only as a request header', async () => { + // The branch above is reached from `installationId` in the request body. The + // header is resolved into the deduplication lookup but not into that branch's + // condition, so a caller that presents it only as a header is rejected instead. + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-first', + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: Object.assign({}, anonymousHeaders, { + 'X-Parse-Installation-Id': 'iid-second', + }), + body: JSON.stringify({ deviceToken: t, deviceType: 'ios' }), + }).catch(error => error); + + expect(response.status).toBe(400); + expect(response.data.code).toBe(132); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-first'); + expect(all[0].deviceToken).toBe(t); + }); + + it('rejects the write when the matched row has an ACL that excludes an unauthenticated caller', async () => { + const t = randomUUID(); + const user = await Parse.User.signUp('installation-acl-' + randomUUID(), 'pass'); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-acl', + channels: ['news'], + ACL: { [user.id]: { read: true, write: true } }, + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: anonymousHeaders, + body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }), + }).catch(error => error); + + expect(response.status).toBe(404); + expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].channels).toEqual(['news']); + }); + + it('rejects the write when the matched row has an ACL that excludes an authenticated non-owner', async () => { + const t = randomUUID(); + const owner = await Parse.User.signUp('installation-owner-' + randomUUID(), 'pass'); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-acl-owner', + channels: ['news'], + ACL: { [owner.id]: { read: true, write: true } }, + }); + const other = await Parse.User.signUp('installation-other-' + randomUUID(), 'pass'); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: Object.assign({}, anonymousHeaders, { + 'X-Parse-Session-Token': other.getSessionToken(), + }), + body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }), + }).catch(error => error); + + expect(response.status).toBe(404); + expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].channels).toEqual(['news']); + }); + + it('applies the write when the caller is granted write access by the matched row ACL', async () => { + const t = randomUUID(); + const owner = await Parse.User.signUp('installation-granted-' + randomUUID(), 'pass'); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-acl-granted', + channels: ['news'], + ACL: { [owner.id]: { read: true, write: true } }, + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/installations', + headers: Object.assign({}, anonymousHeaders, { + 'X-Parse-Session-Token': owner.getSessionToken(), + }), + body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }), + }); + + expect(response.status).toBe(200); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].channels).toEqual(['sports']); + }); + }); describe('options validation', () => { it('should accept default empty config', async () => { await expectAsync(reconfigureServer({})).toBeResolved(); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 1322ac3f2e..3a2988af00 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -787,7 +787,7 @@ module.exports.InstallationOptions = { }, duplicateDeviceTokenActionEnforceAuth: { env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', - help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`.", + help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`.", action: parsers.booleanParser, default: false, }, diff --git a/src/Options/docs.js b/src/Options/docs.js index aad201e454..4540040c66 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -153,7 +153,7 @@ /** * @interface InstallationOptions * @property {String} duplicateDeviceTokenAction What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. - * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`. * @property {String} duplicateDeviceTokenMergePriority At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. */ diff --git a/src/Options/index.js b/src/Options/index.js index 4af4c792b0..5d5fb4fa69 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -492,7 +492,7 @@ export interface RequestComplexityOptions { } export interface InstallationOptions { - /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`. :DEFAULT: false */ duplicateDeviceTokenActionEnforceAuth: ?boolean; /* What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. From 10a688df05ba899549b55984d0e83e7268f879be Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:06:48 +0200 Subject: [PATCH 2/3] refactor: Extract shared class-level permission helper in installation specs --- spec/ParseInstallation.spec.js | 78 ++++++++++++++-------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index abe6eb42d9..c49ae14757 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1417,6 +1417,36 @@ describe('Installations', () => { database = config.database; } + // Grants the public every operation on `_Installation` except those overridden. + // `find` and `delete` are blocked for non-master callers at the REST layer + // regardless, but the deduplication delete runs below that layer, so the + // class-level permission is what it is checked against once `enforceAuth` is on. + async function setInstallationClassLevelPermissions(overrides) { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/_Installation', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: Object.assign( + { + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: { '*': true }, + }, + overrides + ), + }), + }); + expect(response.status).toBe(200); + } + it('default options destroy conflicting rows', async () => { const t = randomUUID(); await rest.create(config, auth.nobody(config), '_Installation', { @@ -1536,31 +1566,7 @@ describe('Installations', () => { installationId: 'iid-clp-existing', }); - const schemaResponse = await request({ - method: 'PUT', - url: 'http://localhost:8378/1/schemas/_Installation', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - classLevelPermissions: { - get: { '*': true }, - find: { '*': true }, - count: { '*': true }, - create: { '*': true }, - update: { '*': true }, - addField: { '*': true }, - // The only operation withheld from the public. `find` and `delete` are - // blocked for non-master callers at the REST layer regardless, but the - // dedup delete runs below that layer, so the class-level permission is - // what it is checked against once `enforceAuth` is on. - delete: {}, - }, - }), - }); - expect(schemaResponse.status).toBe(200); + await setInstallationClassLevelPermissions({ delete: {} }); await rest.create(config, auth.nobody(config), '_Installation', { deviceToken: t, @@ -1590,27 +1596,7 @@ describe('Installations', () => { installationId: 'iid-clp-existing', }); - const schemaResponse = await request({ - method: 'PUT', - url: 'http://localhost:8378/1/schemas/_Installation', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - classLevelPermissions: { - get: { '*': true }, - find: { '*': true }, - count: { '*': true }, - create: { '*': true }, - update: { '*': true }, - addField: { '*': true }, - delete: {}, - }, - }), - }); - expect(schemaResponse.status).toBe(200); + await setInstallationClassLevelPermissions({ delete: {} }); await rest.create(config, auth.nobody(config), '_Installation', { deviceToken: t, From 7036acbc882908e06a79df87f15784083926ec3d Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:20:27 +0200 Subject: [PATCH 3/3] docs: Remove unverified claim about SDK installation ACLs --- README.md | 2 +- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e3629542c9..8d28f74e7d 100644 --- a/README.md +++ b/README.md @@ -686,7 +686,7 @@ Parse Server deduplicates `_Installation` records when a new install collides wi When `true`, the dedup operation runs with the caller's auth context so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. -Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions — which is what the Parse SDKs create, as an unauthenticated device registration has no principal to grant write access to. To scope the deduplication, combine this option with either an ACL on `_Installation` records or class-level permissions that withhold the `delete` operation from the caller. +Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions. To scope the deduplication, combine this option with either an ACL on `_Installation` records or class-level permissions that withhold the `delete` operation from the caller. #### `duplicateDeviceTokenAction` diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 3a2988af00..bc3fc2a41b 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -787,7 +787,7 @@ module.exports.InstallationOptions = { }, duplicateDeviceTokenActionEnforceAuth: { env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', - help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`.", + help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`.", action: parsers.booleanParser, default: false, }, diff --git a/src/Options/docs.js b/src/Options/docs.js index 4540040c66..08e882ac5f 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -153,7 +153,7 @@ /** * @interface InstallationOptions * @property {String} duplicateDeviceTokenAction What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. - * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`. + * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`. * @property {String} duplicateDeviceTokenMergePriority At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. */ diff --git a/src/Options/index.js b/src/Options/index.js index 5d5fb4fa69..af9153010d 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -492,7 +492,7 @@ export interface RequestComplexityOptions { } export interface InstallationOptions { - /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`. + /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`. :DEFAULT: false */ duplicateDeviceTokenActionEnforceAuth: ?boolean; /* What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`.