diff --git a/src/core/protocol-authorization.ts b/src/core/protocol-authorization.ts index 744947f65..04e8cd936 100644 --- a/src/core/protocol-authorization.ts +++ b/src/core/protocol-authorization.ts @@ -212,12 +212,12 @@ export class ProtocolAuthorization { /** * Performs protocol-based authorization against the incoming `RecordsDelete` message. - * @param newestRecordsWrite The latest `RecordsWrite` associated with the recordId being deleted. + * @param recordsWrite A `RecordsWrite` of the record being deleted. */ public static async authorizeDelete( tenant: string, incomingMessage: RecordsDelete, - newestRecordsWrite: RecordsWrite, + recordsWrite: RecordsWrite, messageStore: MessageStore, ): Promise { @@ -228,13 +228,13 @@ export class ProtocolAuthorization { // fetch the protocol definition const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition( tenant, - newestRecordsWrite.message.descriptor.protocol!, + recordsWrite.message.descriptor.protocol!, messageStore, ); // get the rule set for the inbound message const ruleSet = ProtocolAuthorization.getRuleSet( - newestRecordsWrite.message.descriptor.protocolPath!, + recordsWrite.message.descriptor.protocolPath!, protocolDefinition, ); @@ -242,8 +242,8 @@ export class ProtocolAuthorization { await ProtocolAuthorization.verifyInvokedRole( tenant, incomingMessage, - newestRecordsWrite.message.descriptor.protocol!, - newestRecordsWrite.message.contextId!, + recordsWrite.message.descriptor.protocol!, + recordsWrite.message.contextId!, protocolDefinition, messageStore, ); diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index 625ee0e6d..ba14aab13 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -2,18 +2,19 @@ import type { DidResolver } from '@web5/dids'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; +import type { RecordsDeleteMessage } from '../types/records-types.js'; import type { ResumableTaskManager } from '../core/resumable-task-manager.js'; -import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; import { authenticate } from '../core/auth.js'; +import { DwnInterfaceName } from '../enums/dwn-interface-method.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; +import { Records } from '../utils/records.js'; import { RecordsDelete } from '../interfaces/records-delete.js'; import { RecordsWrite } from '../interfaces/records-write.js'; import { ResumableTaskName } from '../core/resumable-task-manager.js'; import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; -import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class RecordsDeleteHandler implements MethodHandler { @@ -51,15 +52,14 @@ export class RecordsDeleteHandler implements MethodHandler { // find which message is the newest, and if the incoming message is the newest const newestExistingMessage = await Message.getNewestMessage(existingMessages); - // return Not Found if record does not exist or is already deleted - if (newestExistingMessage === undefined || newestExistingMessage.descriptor.method === DwnMethodName.Delete) { + if (!Records.canPerformDeleteAgainstRecord(message, newestExistingMessage)) { return { status: { code: 404, detail: 'Not Found' } }; } // if the incoming message is not the newest, return Conflict - const incomingDeleteIsNewest = await Message.isNewer(message, newestExistingMessage); + const incomingDeleteIsNewest = await Message.isNewer(message, newestExistingMessage!); if (!incomingDeleteIsNewest) { return { status: { code: 409, detail: 'Conflict' } @@ -68,10 +68,15 @@ export class RecordsDeleteHandler implements MethodHandler { // authorization try { + // NOTE: We need a RecordsWrite (doesn't have to be initial) to access the immutable properties for delete processing, + // but if the latest record state is a RecordsDelete (ie. when we are pruning a non-prune delete), + // we'd need to use the initial write because RecordsDelete does not contain the immutable properties needed for processing. + const initialWrite = await RecordsWrite.fetchInitialRecordsWrite(this.messageStore, tenant, message.descriptor.recordId); + await RecordsDeleteHandler.authorizeRecordsDelete( tenant, recordsDelete, - await RecordsWrite.parse(newestExistingMessage as RecordsWriteMessage), + initialWrite!, this.messageStore ); } catch (e) { @@ -92,23 +97,23 @@ export class RecordsDeleteHandler implements MethodHandler { /** * Authorizes a RecordsDelete message. * - * @param newestRecordsWrite Newest RecordsWrite of the record to be deleted. + * @param recordsWrite A RecordsWrite of the record to be deleted. */ private static async authorizeRecordsDelete( tenant: string, recordsDelete: RecordsDelete, - newestRecordsWrite: RecordsWrite, + recordsWrite: RecordsWrite, messageStore: MessageStore ): Promise { if (Message.isSignedByAuthorDelegate(recordsDelete.message)) { - await recordsDelete.authorizeDelegate(newestRecordsWrite.message, messageStore); + await recordsDelete.authorizeDelegate(recordsWrite.message, messageStore); } if (recordsDelete.author === tenant) { return; - } else if (newestRecordsWrite.message.descriptor.protocol !== undefined) { - await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, newestRecordsWrite, messageStore); + } else if (recordsWrite.message.descriptor.protocol !== undefined) { + await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, recordsWrite, messageStore); } else { throw new DwnError( DwnErrorCode.RecordsDeleteAuthorizationFailed, diff --git a/src/store/storage-controller.ts b/src/store/storage-controller.ts index 2fd42ae56..5ed2449cb 100644 --- a/src/store/storage-controller.ts +++ b/src/store/storage-controller.ts @@ -51,8 +51,7 @@ export class StorageController { // find which message is the newest, and if the incoming message is the newest const newestExistingMessage = await Message.getNewestMessage(existingMessages); - // if no messages found for the record, nothing to do - if (newestExistingMessage === undefined || newestExistingMessage.descriptor.method === DwnMethodName.Delete) { + if (!Records.canPerformDeleteAgainstRecord(message, newestExistingMessage)) { return; } diff --git a/src/utils/records.ts b/src/utils/records.ts index 891786b54..ff30543b0 100644 --- a/src/utils/records.ts +++ b/src/utils/records.ts @@ -487,14 +487,14 @@ export class Records { /** * Determines if signature payload contains a protocolRole and should be authorized as such. */ - static shouldProtocolAuthorize(signaturePayload: GenericSignaturePayload): boolean { + public static shouldProtocolAuthorize(signaturePayload: GenericSignaturePayload): boolean { return signaturePayload.protocolRole !== undefined; } /** * Checks if the filter supports returning published records. */ - static filterIncludesPublishedRecords(filter: RecordsFilter): boolean { + public static filterIncludesPublishedRecords(filter: RecordsFilter): boolean { // NOTE: published records should still be returned when `published` and `datePublished` range are both undefined. return filter.datePublished !== undefined || filter.published !== false; } @@ -502,11 +502,33 @@ export class Records { /** * Checks if the filter supports returning unpublished records. */ - static filterIncludesUnpublishedRecords(filter: RecordsFilter): boolean { + public static filterIncludesUnpublishedRecords(filter: RecordsFilter): boolean { // When `published` and `datePublished` range are both undefined, unpublished records can be returned. if (filter.datePublished === undefined && filter.published === undefined) { return true; } return filter.published === false; } + + /** + * Checks if the given RecordsDelete message can be performed against a record with the given newest existing state. + */ + public static canPerformDeleteAgainstRecord(deleteToBePerformed: RecordsDeleteMessage, newestExistingMessage: GenericMessage | undefined): boolean { + if (newestExistingMessage === undefined) { + return false; + } + + // can't perform delete if: + // attempting to delete on an already deleted record; or + // attempting to prune on an already pruned record; + if (newestExistingMessage.descriptor.method === DwnMethodName.Delete) { + if (deleteToBePerformed.descriptor.prune !== true) { + return false; + } else if ((newestExistingMessage as RecordsDeleteMessage).descriptor.prune === true) { + return false; + } + } + + return true; + } } diff --git a/tests/features/records-prune.spec.ts b/tests/features/records-prune.spec.ts index 47fec043b..be2259283 100644 --- a/tests/features/records-prune.spec.ts +++ b/tests/features/records-prune.spec.ts @@ -63,7 +63,7 @@ export function testRecordsPrune(): void { await dwn.close(); }); - it('should purge all descendants when given RecordsDelete with `prune` set to `true`', async () => { + it('should prune all descendants when given RecordsDelete with `prune` set to `true`', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); // install a protocol with foo <- bar <- baz structure @@ -214,6 +214,228 @@ export function testRecordsPrune(): void { expect(reply2.entries![0]).to.deep.include(foo2.message); }); + it('should allow pruning against a deleted record that is not already pruned', async () => { + // Scenario: + // 1. Alice has a record `foo` with a descendent chain + // 2. Alice deletes the record `foo` WITHOUT prune, leaving the descendants intact + // 3. Verify that Alice is able to perform a prune on `foo` to delete all its descendants + + const alice = await TestDataGenerator.generateDidKeyPersona(); + + // install a protocol with foo <- bar <- baz structure + const nestedProtocol = nestedProtocolDefinition; + const protocolsConfig = await ProtocolsConfigure.create({ + definition : nestedProtocol, + signer : Jws.createSigner(alice) + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // 1. Alice has a record `foo` with a descendent chain + // write foo <- bar <- baz records + + const fooData = TestDataGenerator.randomBytes(100); + const fooOptions = { + signer : Jws.createSigner(alice), + protocol : nestedProtocol.protocol, + protocolPath : 'foo', + schema : nestedProtocol.types.foo.schema, + dataFormat : nestedProtocol.types.foo.dataFormats[0], + data : fooData + }; + const foo = await RecordsWrite.create(fooOptions); + const fooWriteResponse = await dwn.processMessage(alice.did, foo.message, { dataStream: DataStream.fromBytes(fooData) }); + expect(fooWriteResponse.status.code).equals(202); + + const barData = TestDataGenerator.randomBytes(100); + const barOptions = { + signer : Jws.createSigner(alice), + protocol : nestedProtocol.protocol, + protocolPath : 'foo/bar', + schema : nestedProtocol.types.bar.schema, + dataFormat : nestedProtocol.types.bar.dataFormats[0], + parentContextId : foo.message.contextId, + data : barData + }; + const bar = await RecordsWrite.create({ ...barOptions }); + const barWriteResponse = await dwn.processMessage(alice.did, bar.message, { dataStream: DataStream.fromBytes(barData) }); + expect(barWriteResponse.status.code).equals(202); + + const bazData = TestDataGenerator.randomBytes(100); + const bazOptions = { + signer : Jws.createSigner(alice), + protocol : nestedProtocol.protocol, + protocolPath : 'foo/bar/baz', + schema : nestedProtocol.types.baz.schema, + dataFormat : nestedProtocol.types.baz.dataFormats[0], + parentContextId : bar.message.contextId, + data : bazData + }; + + const baz = await RecordsWrite.create({ ...bazOptions }); + const bazWriteResponse = await dwn.processMessage(alice.did, baz.message, { dataStream: DataStream.fromBytes(bazData) }); + expect(bazWriteResponse.status.code).equals(202); + + // sanity records are inserted in message store + const queryFilter = [{ + interface : DwnInterfaceName.Records, + protocol : nestedProtocol.protocol + }]; + const messagesBeforeDelete = await messageStore.query(alice.did, queryFilter); + expect(messagesBeforeDelete.messages.length).to.equal(3); + + // sanity verify RecordsQuery returns no records + const recordsQuery = await RecordsQuery.create({ + signer : Jws.createSigner(alice), + filter : { protocol: nestedProtocol.protocol } + }); + const recordsQueryBeforeDeleteReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(recordsQueryBeforeDeleteReply.status.code).to.equal(200); + expect(recordsQueryBeforeDeleteReply.entries?.length).to.equal(3); + + + // 2. Alice deletes the record `foo` WITHOUT prune, leaving the descendants intact + const fooDelete = await RecordsDelete.create({ + recordId : foo.message.recordId, + // prune : true, // intentionally showing that this is a RecordsDelete WITHOUT pruning + signer : Jws.createSigner(alice) + }); + + const deleteReply = await dwn.processMessage(alice.did, fooDelete.message); + expect(deleteReply.status.code).to.equal(202); + + // verify bar and baz messages still exists + const messagesAfterDelete = await messageStore.query(alice.did, queryFilter, { messageTimestamp: SortDirection.Ascending }); + expect(messagesAfterDelete.messages.length).to.equal(4); // RecordsWrite for foo, bar, baz, and RecordsDelete for foo + + // sanity verify RecordsQuery returns the descendants + const recordsQueryAfterDeleteReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(recordsQueryAfterDeleteReply.status.code).to.equal(200); + expect(recordsQueryAfterDeleteReply.entries?.length).to.equal(2); + + // 3. Verify that Alice is able to perform a prune on `foo` to delete all its descendants + const fooPrune = await RecordsDelete.create({ + recordId : foo.message.recordId, + prune : true, + signer : Jws.createSigner(alice) + }); + + const pruneReply = await dwn.processMessage(alice.did, fooPrune.message); + expect(pruneReply.status.code).to.equal(202); + + // verify bar and baz messages are permanently deleted + const messagesAfterPrune = await messageStore.query(alice.did, queryFilter, { messageTimestamp: SortDirection.Ascending }); + expect(messagesAfterPrune.messages.length).to.equal(2); // just RecordsWrite and RecordsDelete for foo + expect(messagesAfterPrune.messages[0]).to.deep.include(foo.message); + expect(messagesAfterPrune.messages[1]).to.deep.include(fooPrune.message); + + // sanity verify RecordsQuery returns no records + const recordsQueryAfterPruneReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(recordsQueryAfterPruneReply.status.code).to.equal(200); + expect(recordsQueryAfterPruneReply.entries?.length).to.equal(0); + }); + + it('should return 404 when attempting to prune against a record that is already pruned', async () => { + // Scenario: + // 1. Alice has a record `foo` with a descendent chain + // 2. Alice prunes the record `foo` + // 3. Verify that Alice is unable to perform a prune on `foo` again + + const alice = await TestDataGenerator.generateDidKeyPersona(); + + // install a protocol with foo <- bar <- baz structure + const nestedProtocol = nestedProtocolDefinition; + const protocolsConfig = await ProtocolsConfigure.create({ + definition : nestedProtocol, + signer : Jws.createSigner(alice) + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // 1. Alice has a record `foo` with a descendent chain + // write foo <- bar <- baz records + + const fooData = TestDataGenerator.randomBytes(100); + const fooOptions = { + signer : Jws.createSigner(alice), + protocol : nestedProtocol.protocol, + protocolPath : 'foo', + schema : nestedProtocol.types.foo.schema, + dataFormat : nestedProtocol.types.foo.dataFormats[0], + data : fooData + }; + const foo = await RecordsWrite.create(fooOptions); + const fooWriteResponse = await dwn.processMessage(alice.did, foo.message, { dataStream: DataStream.fromBytes(fooData) }); + expect(fooWriteResponse.status.code).equals(202); + + const barData = TestDataGenerator.randomBytes(100); + const barOptions = { + signer : Jws.createSigner(alice), + protocol : nestedProtocol.protocol, + protocolPath : 'foo/bar', + schema : nestedProtocol.types.bar.schema, + dataFormat : nestedProtocol.types.bar.dataFormats[0], + parentContextId : foo.message.contextId, + data : barData + }; + const bar = await RecordsWrite.create({ ...barOptions }); + const barWriteResponse = await dwn.processMessage(alice.did, bar.message, { dataStream: DataStream.fromBytes(barData) }); + expect(barWriteResponse.status.code).equals(202); + + const bazData = TestDataGenerator.randomBytes(100); + const bazOptions = { + signer : Jws.createSigner(alice), + protocol : nestedProtocol.protocol, + protocolPath : 'foo/bar/baz', + schema : nestedProtocol.types.baz.schema, + dataFormat : nestedProtocol.types.baz.dataFormats[0], + parentContextId : bar.message.contextId, + data : bazData + }; + + const baz = await RecordsWrite.create({ ...bazOptions }); + const bazWriteResponse = await dwn.processMessage(alice.did, baz.message, { dataStream: DataStream.fromBytes(bazData) }); + expect(bazWriteResponse.status.code).equals(202); + + // sanity records are inserted in message store + const queryFilter = [{ + interface : DwnInterfaceName.Records, + protocol : nestedProtocol.protocol + }]; + const queryResult = await messageStore.query(alice.did, queryFilter); + expect(queryResult.messages.length).to.equal(3); + + // sanity verify RecordsQuery returns no records + const recordsQuery = await RecordsQuery.create({ + signer : Jws.createSigner(alice), + filter : { protocol: nestedProtocol.protocol } + }); + const recordsQueryBeforeDeleteReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(recordsQueryBeforeDeleteReply.status.code).to.equal(200); + expect(recordsQueryBeforeDeleteReply.entries?.length).to.equal(3); + + + // 2. Alice prunes the record `foo` + const fooPrune1 = await RecordsDelete.create({ + recordId : foo.message.recordId, + prune : true, + signer : Jws.createSigner(alice) + }); + + const prune1Reply = await dwn.processMessage(alice.did, fooPrune1.message); + expect(prune1Reply.status.code).to.equal(202); + + // 3. Verify that Alice is unable to perform a prune on `foo` again + const fooPrune2 = await RecordsDelete.create({ + recordId : foo.message.recordId, + prune : true, + signer : Jws.createSigner(alice) + }); + + const prune2Reply = await dwn.processMessage(alice.did, fooPrune2.message); + expect(prune2Reply.status.code).to.equal(404); + }); + describe('prune and co-prune protocol action', () => { it('should only allow a non-owner author to prune if `prune` is allowed and set to `true` in RecordsDelete', async () => { // Scenario: