From 9ea4b3c48858214249b8d9c4c7f1d48a43acf464 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 21 Jun 2024 17:20:12 -0400 Subject: [PATCH] messages get authorization --- .../permissions/permissions-definitions.json | 3 + json-schemas/permissions/scopes.json | 14 + src/core/dwn-error.ts | 6 +- src/core/messages-grant-authorization.ts | 94 +++ src/handlers/messages-get.ts | 53 +- src/interfaces/messages-get.ts | 12 +- src/types/messages-types.ts | 3 +- src/types/permission-types.ts | 1 + tests/handlers/messages-get.spec.ts | 671 +++++++++++++++--- tests/interfaces/messages-get.spec.ts | 10 +- tests/utils/test-data-generator.ts | 4 +- 11 files changed, 743 insertions(+), 128 deletions(-) create mode 100644 src/core/messages-grant-authorization.ts diff --git a/json-schemas/permissions/permissions-definitions.json b/json-schemas/permissions/permissions-definitions.json index 4a1f579a3..d32804679 100644 --- a/json-schemas/permissions/permissions-definitions.json +++ b/json-schemas/permissions/permissions-definitions.json @@ -5,6 +5,9 @@ "$defs": { "scope": { "oneOf": [ + { + "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/$defs/messages-get-scope" + }, { "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/$defs/protocols-query-scope" }, diff --git a/json-schemas/permissions/scopes.json b/json-schemas/permissions/scopes.json index 6ef8e60f4..c516c860f 100644 --- a/json-schemas/permissions/scopes.json +++ b/json-schemas/permissions/scopes.json @@ -3,6 +3,20 @@ "$id": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json", "type": "object", "$defs": { + "messages-get-scope": { + "type": "object", + "properties": { + "interface": { + "const": "Messages" + }, + "method": { + "const": "Get" + }, + "protocol": { + "type": "string" + } + } + }, "protocols-query-scope": { "type": "object", "properties": { diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 7b9d4912c..2de7631c7 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -44,11 +44,15 @@ export enum DwnErrorCode { IndexInvalidSortPropertyInMemory = 'IndexInvalidSortPropertyInMemory', IndexMissingIndexableProperty = 'IndexMissingIndexableProperty', JwsDecodePlainObjectPayloadInvalid = 'JwsDecodePlainObjectPayloadInvalid', - MessageGetInvalidCid = 'MessageGetInvalidCid', + MessagesGetInvalidCid = 'MessagesGetInvalidCid', + MessagesGetWriteRecordNotFound = 'MessagesGetWriteRecordNotFound', + MessagesGetAuthorizationFailed = 'MessagesGetAuthorizationFailed', + MessagesGetVerifyScopeFailed = 'MessagesGetVerifyScopeFailed', ParseCidCodecNotSupported = 'ParseCidCodecNotSupported', ParseCidMultihashNotSupported = 'ParseCidMultihashNotSupported', PermissionsProtocolCreateGrantRecordsScopeMissingProtocol = 'PermissionsProtocolCreateGrantRecordsScopeMissingProtocol', PermissionsProtocolCreateRequestRecordsScopeMissingProtocol = 'PermissionsProtocolCreateRequestRecordsScopeMissingProtocol', + PermissionsProtocolInvalidProtocolURI = 'PermissionsProtocolInvalidProtocolURI', PermissionsProtocolValidateSchemaUnexpectedRecord = 'PermissionsProtocolValidateSchemaUnexpectedRecord', PermissionsProtocolValidateScopeContextIdProhibitedProperties = 'PermissionsProtocolValidateScopeContextIdProhibitedProperties', PermissionsProtocolValidateScopeProtocolMismatch = 'PermissionsProtocolValidateScopeProtocolMismatch', diff --git a/src/core/messages-grant-authorization.ts b/src/core/messages-grant-authorization.ts new file mode 100644 index 000000000..1d5d2a6ac --- /dev/null +++ b/src/core/messages-grant-authorization.ts @@ -0,0 +1,94 @@ +import type { GenericMessage } from '../types/message-types.js'; +import type { MessagesGetMessage } from '../types/messages-types.js'; +import type { MessagesPermissionScope } from '../types/permission-types.js'; +import type { MessageStore } from '../types/message-store.js'; +import type { PermissionGrant } from '../protocols/permission-grant.js'; +import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; + +import { GrantAuthorization } from './grant-authorization.js'; +import { Message } from './message.js'; +import { Records } from '../utils/records.js'; +import { DwnError, DwnErrorCode } from './dwn-error.js'; +import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; + +export class MessagesGrantAuthorization { + + /** + * Authorizes a RecordsReadMessage using the given permission grant. + * @param messageStore Used to check if the given grant has been revoked. + */ + public static async authorizeMessagesGetGrant(input: { + messagesGetMessage: MessagesGetMessage, + messageToGet: GenericMessage, + expectedGrantor: string, + expectedGrantee: string, + permissionGrant: PermissionGrant, + messageStore: MessageStore, + }): Promise { + const { + messagesGetMessage, messageToGet, expectedGrantor, expectedGrantee, permissionGrant, messageStore + } = input; + + await GrantAuthorization.performBaseValidation({ + incomingMessage: messagesGetMessage, + expectedGrantor, + expectedGrantee, + permissionGrant, + messageStore + }); + + const scope = permissionGrant.scope as MessagesPermissionScope; + await MessagesGrantAuthorization.verifyScope(expectedGrantor, messageToGet, scope, messageStore); + } + + /** + * Verifies the given record against the scope of the given grant. + */ + private static async verifyScope( + tenant: string, + messageToGet: GenericMessage, + incomingScope: MessagesPermissionScope, + messageStore: MessageStore, + ): Promise { + if (incomingScope.protocol === undefined) { + // if no protocol is specified in the scope, then the grant is for all records + return; + } + + if (messageToGet.descriptor.interface === DwnInterfaceName.Records) { + const recordsMessage = messageToGet as RecordsWriteMessage | RecordsDeleteMessage; + const recordsWriteMessage = Records.isRecordsWrite(recordsMessage) ? recordsMessage : + await MessagesGrantAuthorization.getRecordsWriteMessageToAuthorize(tenant, recordsMessage, messageStore); + + if (recordsWriteMessage.descriptor.protocol === incomingScope.protocol) { + // the record protocol matches the incoming scope protocol + return; + } + } + + throw new DwnError(DwnErrorCode.MessagesGetVerifyScopeFailed, 'record message failed scope authorization'); + } + + private static async getRecordsWriteMessageToAuthorize( + tenant: string, + message: RecordsDeleteMessage, + messageStore: MessageStore + ): Promise { + // get existing RecordsWrite messages matching the `recordId` + const query = { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + recordId : message.descriptor.recordId + }; + + const { messages: existingMessages } = await messageStore.query(tenant, [ query ]); + const newestWrite = await Message.getNewestMessage(existingMessages); + if (newestWrite !== undefined) { + return newestWrite as RecordsWriteMessage; + } + + // It shouldn't be possible to get here, as the `RecordsDeleteMessage` should always have a corresponding `RecordsWriteMessage`. + // But we add this in for defensive programming + throw new DwnError(DwnErrorCode.MessagesGetWriteRecordNotFound, 'record not found'); + } +} \ No newline at end of file diff --git a/src/handlers/messages-get.ts b/src/handlers/messages-get.ts index 637ef2092..a6bb117fa 100644 --- a/src/handlers/messages-get.ts +++ b/src/handlers/messages-get.ts @@ -1,16 +1,20 @@ -import type { DataEncodedRecordsWriteMessage } from '../types/records-types.js'; import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '@web5/dids'; +import type { GenericMessage } from '../types/message-types.js'; import type { MessageStore } from '../types/message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; +import type { RecordsQueryReplyEntry } from '../types/records-types.js'; import type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from '../types/messages-types.js'; +import { authenticate } from '../core/auth.js'; import { DataStream } from '../utils/data-stream.js'; import { Encoder } from '../utils/encoder.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { MessagesGet } from '../interfaces/messages-get.js'; +import { MessagesGrantAuthorization } from '../core/messages-grant-authorization.js'; +import { PermissionsProtocol } from '../protocols/permissions.js'; import { Records } from '../utils/records.js'; -import { authenticate, authorizeOwner } from '../core/auth.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; type HandleArgs = { tenant: string, message: MessagesGetMessage }; @@ -28,23 +32,27 @@ export class MessagesGetHandler implements MethodHandler { try { await authenticate(message.authorization, this.didResolver); - await authorizeOwner(tenant, messagesGet); } catch (e) { return messageReplyFromError(e, 401); } const messageResult = await this.messageStore.get(tenant, message.descriptor.messageCid); - if (messageResult === undefined) { return { status: { code: 404, detail: 'Not Found' } }; } + try { + await MessagesGetHandler.authorizeMessagesGet(tenant, messagesGet, messageResult, this.messageStore); + } catch (error) { + return messageReplyFromError(error, 401); + } + // Include associated data as `encodedData` IF: // * its a RecordsWrite // * `encodedData` exists which means the data size is equal or smaller than the size threshold const entry: MessagesGetReplyEntry = { message: messageResult, messageCid: message.descriptor.messageCid }; - if (entry.message && Records.isRecordsWrite(messageResult)) { - const recordsWrite = entry.message as DataEncodedRecordsWriteMessage; + if (Records.isRecordsWrite(messageResult)) { + const recordsWrite = entry.message as RecordsQueryReplyEntry; // RecordsWrite specific handling, if MessageStore has embedded `encodedData` return it with the entry. // we store `encodedData` along with the message if the data is below a certain threshold. if (recordsWrite.encodedData !== undefined) { @@ -52,11 +60,13 @@ export class MessagesGetHandler implements MethodHandler { entry.message.data = DataStream.fromBytes(dataBytes); delete recordsWrite.encodedData; } else { + // check the data store for the associated data const result = await this.dataStore.get(tenant, recordsWrite.recordId, recordsWrite.descriptor.dataCid); if (result?.dataStream !== undefined) { entry.message.data = result.dataStream; } else { - delete entry.message.data; // if there is no data, return with the data property undefined + // if there is no data, return with the data property undefined + delete entry.message.data; } } } @@ -66,4 +76,33 @@ export class MessagesGetHandler implements MethodHandler { entry }; } + + /** + * @param messageStore Used to check if the grant has been revoked. + */ + private static async authorizeMessagesGet( + tenant: string, + messagesGet: MessagesGet, + matchedMessage: GenericMessage, + messageStore: MessageStore + ): Promise { + + if (messagesGet.author === tenant) { + // If the author is the tenant, no further authorization is needed + return; + } if (messagesGet.author !== undefined && messagesGet.signaturePayload!.permissionGrantId !== undefined) { + // if the author is not the tenant and the message has a permissionGrantId, we need to authorize the grant + const permissionGrant = await PermissionsProtocol.fetchGrant(tenant, messageStore, messagesGet.signaturePayload!.permissionGrantId); + await MessagesGrantAuthorization.authorizeMessagesGetGrant({ + messagesGetMessage : messagesGet.message, + messageToGet : matchedMessage, + expectedGrantor : tenant, + expectedGrantee : messagesGet.author, + permissionGrant, + messageStore + }); + } else { + throw new DwnError(DwnErrorCode.MessagesGetAuthorizationFailed, 'protocol message failed authorization'); + } + } } \ No newline at end of file diff --git a/src/interfaces/messages-get.ts b/src/interfaces/messages-get.ts index 5e15567ab..c75115052 100644 --- a/src/interfaces/messages-get.ts +++ b/src/interfaces/messages-get.ts @@ -12,6 +12,7 @@ export type MessagesGetOptions = { messageCid: string; signer: Signer; messageTimestamp?: string; + permissionGrantId?: string; }; export class MessagesGet extends AbstractMessage { @@ -30,10 +31,15 @@ export class MessagesGet extends AbstractMessage { interface : DwnInterfaceName.Messages, method : DwnMethodName.Get, messageCid : options.messageCid, - messageTimestamp : options?.messageTimestamp ?? Time.getCurrentTimestamp(), + messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), }; - const authorization = await Message.createAuthorization({ descriptor, signer: options.signer }); + const { signer, permissionGrantId } = options; + const authorization = await Message.createAuthorization({ + descriptor, + signer, + permissionGrantId, + }); const message = { descriptor, authorization }; Message.validateJsonSchema(message); @@ -51,7 +57,7 @@ export class MessagesGet extends AbstractMessage { try { Cid.parseCid(messageCid); } catch (_) { - throw new DwnError(DwnErrorCode.MessageGetInvalidCid, `${messageCid} is not a valid CID`); + throw new DwnError(DwnErrorCode.MessagesGetInvalidCid, `${messageCid} is not a valid CID`); } } } \ No newline at end of file diff --git a/src/types/messages-types.ts b/src/types/messages-types.ts index 0d9d8e9ed..a36894ff4 100644 --- a/src/types/messages-types.ts +++ b/src/types/messages-types.ts @@ -16,8 +16,7 @@ export type MessagesGetMessage = GenericMessage & { export type MessagesGetReplyEntry = { messageCid: string; - message?: (GenericMessage & { data?: Readable }); - error?: string; + message: (GenericMessage & { data?: Readable }); }; export type MessagesGetReply = GenericMessageReply & { diff --git a/src/types/permission-types.ts b/src/types/permission-types.ts index 1e7adc055..5c2915ea4 100644 --- a/src/types/permission-types.ts +++ b/src/types/permission-types.ts @@ -78,6 +78,7 @@ export type ProtocolPermissionScope = { export type MessagesPermissionScope = { interface: DwnInterfaceName.Messages; method: DwnMethodName.Get; + protocol?: string; }; export type EventsPermissionScope = { diff --git a/tests/handlers/messages-get.spec.ts b/tests/handlers/messages-get.spec.ts index f1d159d5e..6c1caa7cb 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -9,11 +9,13 @@ import type { } from '../../src/index.js'; import { expect } from 'chai'; +import { GeneralJwsVerifier } from '../../src/jose/jws/general/verifier.js'; import { Message } from '../../src/core/message.js'; +import minimalProtocolDefinition from '../vectors/protocol-definitions/minimal.json' assert { type: 'json' }; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; -import { DataStream, Dwn, DwnConstant } from '../../src/index.js'; +import { DataStream, Dwn, DwnConstant, DwnErrorCode, DwnInterfaceName, DwnMethodName, Jws, PermissionsProtocol, Time } from '../../src/index.js'; import { DidKey, UniversalResolver } from '@web5/dids'; import sinon from 'sinon'; @@ -54,22 +56,25 @@ export function testMessagesGetHandler(): void { }); after(async () => { + sinon.restore(); await dwn.close(); }); - it('returns a 401 if tenant is not author', async () => { + it('returns 401 if authentication fails', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); - const bob = await TestDataGenerator.generateDidKeyPersona(); - const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author: alice }); + sinon.stub(GeneralJwsVerifier, 'verifySignatures').throws(new Error('Invalid signature')); + + // alice creates a record const { message } = await TestDataGenerator.generateMessagesGet({ author : alice, - messageCid : await Message.getCid(recordsWrite.message) + messageCid : await TestDataGenerator.randomCborSha256Cid() }); - const reply = await dwn.processMessage(bob.did, message); - + // alice is not the author of the message + const reply = await dwn.processMessage(alice.did, message); expect(reply.status.code).to.equal(401); + expect(reply.status.detail).to.include('Invalid signature'); }); it('returns a 400 if message is invalid', async () => { @@ -106,7 +111,7 @@ export function testMessagesGetHandler(): void { expect(reply.entry).to.be.undefined; }); - it('returns message as undefined in reply entry when a messageCid is not found', async () => { + it('returns a 404 and the entry as undefined in reply entry when a messageCid is not found', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author: alice }); const recordsWriteMessageCid = await Message.getCid(recordsWrite.message); @@ -119,134 +124,580 @@ export function testMessagesGetHandler(): void { // returns a 404 because the RecordsWrite created above was never stored const reply: MessagesGetReply = await dwn.processMessage(alice.did, message); expect(reply.status.code).to.equal(404); + expect(reply.entry).to.be.undefined; }); - describe('gets data in the reply entry', () => { - it('data is less than threshold', async () => { - const alice = await TestDataGenerator.generateDidKeyPersona(); - - const { recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ - author : alice, - data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded), + describe('without a grant', () =>{ + describe('records interface messages', () => { + it('returns 401 if the tenant is not the author', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + // bob creates a record that alice will try and get + const { message: recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: bob }); + const { status } = await dwn.processMessage(bob.did, recordsWrite, { dataStream }); + expect(status.code).to.equal(202); + + // alice tries to get the message + const { message } = await TestDataGenerator.generateMessagesGet({ + author : alice, + messageCid : await Message.getCid(recordsWrite) + }); + const reply = await dwn.processMessage(bob.did, message); + + expect(reply.status.code).to.equal(401); + expect(reply.status.detail).to.include(DwnErrorCode.MessagesGetAuthorizationFailed); }); - const reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); - expect(reply.status.code).to.equal(202); - - const recordsWriteMessageCid = await Message.getCid(recordsWrite.message); - const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCid : recordsWriteMessageCid + describe('gets record data in the reply entry', () => { + it('data is less than threshold', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + + const { message: recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded), + }); + + const reply = await dwn.processMessage(alice.did, recordsWrite, { dataStream }); + expect(reply.status.code).to.equal(202); + + const recordsWriteMessageCid = await Message.getCid(recordsWrite); + const { message } = await TestDataGenerator.generateMessagesGet({ + author : alice, + messageCid : recordsWriteMessageCid + }); + + const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); + expect(messagesGetReply.status.code).to.equal(200); + expect(messagesGetReply.entry).to.exist; + + const messageReply = messagesGetReply.entry!; + expect(messageReply.messageCid).to.exist; + expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); + + expect(messageReply.message).to.exist.and.not.be.undefined; + expect(messageReply.message?.data).to.exist.and.not.be.undefined; + const messageData = await DataStream.toBytes(messageReply.message!.data!); + expect(messageData).to.eql(dataBytes); + }); + + it('data is greater than threshold', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + + const { message: recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), + }); + + const reply = await dwn.processMessage(alice.did, recordsWrite, { dataStream }); + expect(reply.status.code).to.equal(202); + + const recordsWriteMessageCid = await Message.getCid(recordsWrite); + const { message } = await TestDataGenerator.generateMessagesGet({ + author : alice, + messageCid : recordsWriteMessageCid + }); + + const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); + expect(messagesGetReply.status.code).to.equal(200); + expect(messagesGetReply.entry).to.exist; + + const messageReply = messagesGetReply.entry!; + expect(messageReply.messageCid).to.exist; + expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); + + expect(messageReply.message).to.exist.and.not.be.undefined; + expect(messageReply.message?.data).to.exist.and.not.be.undefined; + const messageData = await DataStream.toBytes(messageReply.message!.data!); + expect(messageData).to.eql(dataBytes); + }); + + it('data is not available', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + + // initial write + const { message: recordsWriteMessage, recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), + }); + + const initialMessageCid = await Message.getCid(recordsWriteMessage); + + let reply = await dwn.processMessage(alice.did, recordsWriteMessage, { dataStream }); + expect(reply.status.code).to.equal(202); + + const { recordsWrite: updateMessage, dataStream: updateDataStream } = await TestDataGenerator.generateFromRecordsWrite({ + author : alice, + existingWrite : recordsWrite, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), + }); + + reply = await dwn.processMessage(alice.did, updateMessage.toJSON(), { dataStream: updateDataStream }); + expect(reply.status.code).to.equal(202); + + const { message } = await TestDataGenerator.generateMessagesGet({ + author : alice, + messageCid : initialMessageCid + }); + + const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); + expect(messagesGetReply.status.code).to.equal(200); + expect(messagesGetReply.entry).to.exist; + + const messageReply = messagesGetReply.entry!; + expect(messageReply.messageCid).to.exist; + expect(messageReply.messageCid).to.equal(initialMessageCid); + + expect(messageReply.message).to.exist.and.not.be.undefined; + expect(messageReply.message?.data).to.be.undefined; + }); }); - - const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); - expect(messagesGetReply.status.code).to.equal(200); - expect(messagesGetReply.entry).to.exist; - - const messageReply = messagesGetReply.entry!; - expect(messageReply.messageCid).to.exist; - expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); - - expect(messageReply.message).to.exist.and.not.be.undefined; - expect(messageReply.message?.data).to.exist.and.not.be.undefined; - const messageData = await DataStream.toBytes(messageReply.message!.data!); - expect(messageData).to.eql(dataBytes); }); - it('data is greater than threshold', async () => { - const alice = await TestDataGenerator.generateDidKeyPersona(); - - const { recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ - author : alice, - data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), - }); - - const reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); - expect(reply.status.code).to.equal(202); - - const recordsWriteMessageCid = await Message.getCid(recordsWrite.message); - const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCid : recordsWriteMessageCid + describe('protocol interface messages', () => { + it('returns 401 if the tenant is not the author', async () => { + // scenario: Alice creates a non-published protocol, installs it, and writes a record. Bob tries to get the protocol message. + // Bob is unable to get the protocol message because it is not published. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = { ...minimalProtocolDefinition, published: false }; + const { message: protocolsConfigure } = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfigure); + expect(protocolsConfigureReply.status.code).to.equal(202); + + const protocolMessageCid = await Message.getCid(protocolsConfigure); + + // bob attempts to get the message + const { message: getProtocolConfigure } = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : protocolMessageCid, + }); + const getProtocolConfigureReply = await dwn.processMessage(alice.did, getProtocolConfigure); + expect(getProtocolConfigureReply.status.code).to.equal(401); + expect(getProtocolConfigureReply.status.detail).to.include(DwnErrorCode.MessagesGetAuthorizationFailed); + expect(getProtocolConfigureReply.entry).to.be.undefined; }); - - const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); - expect(messagesGetReply.status.code).to.equal(200); - expect(messagesGetReply.entry).to.exist; - - const messageReply = messagesGetReply.entry!; - expect(messageReply.messageCid).to.exist; - expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); - - expect(messageReply.message).to.exist.and.not.be.undefined; - expect(messageReply.message?.data).to.exist.and.not.be.undefined; - const messageData = await DataStream.toBytes(messageReply.message!.data!); - expect(messageData).to.eql(dataBytes); }); + }); + + describe('with a grant', () => { + it('rejects with 401 an external party attempts to MessagesGet if grant has different DWN interface scope', async () => { + // scenario: Alice grants Bob access to RecordsWrite, then Bob tries to invoke the grant with MessagesGet - it('data is not available', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); - // initial write + // Alice writes a record which Bob will later try to read const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ - author : alice, - data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), + author: alice, }); - - const initialMessageCid = await Message.getCid(recordsWrite.message); - - let reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); - expect(reply.status.code).to.equal(202); - - const { recordsWrite: updateMessage, dataStream: updateDataStream } = await TestDataGenerator.generateFromRecordsWrite({ - author : alice, - existingWrite : recordsWrite, - data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), + const recordsWriteReply = await dwn.processMessage(alice.did, recordsWrite.message, { dataStream }); + expect(recordsWriteReply.status.code).to.equal(202); + + // Alice gives Bob a permission grant with scope MessagesGet + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : minimalProtocolDefinition.protocol, + } }); - - reply = await dwn.processMessage(alice.did, updateMessage.toJSON(), { dataStream: updateDataStream }); - expect(reply.status.code).to.equal(202); - - const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCid : initialMessageCid + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const permissionGrantWriteReply = await dwn.processMessage( + alice.did, + permissionGrant.recordsWrite.message, + { dataStream: grantDataStream } + ); + expect(permissionGrantWriteReply.status.code).to.equal(202); + + // Bob tries to MessagesGet + const messagesGet = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : await Message.getCid(recordsWrite.message), + permissionGrantId : permissionGrant.recordsWrite.message.recordId, }); + const messagesGetReply = await dwn.processMessage(alice.did, messagesGet.message); + expect(messagesGetReply.status.code).to.equal(401); + expect(messagesGetReply.status.detail).to.contain(DwnErrorCode.GrantAuthorizationInterfaceMismatch); + }); - const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); - expect(messagesGetReply.status.code).to.equal(200); - expect(messagesGetReply.entry).to.exist; + it('allows external parties to get a message using a grant with unrestricted scope', async () => { + // scenario: Alice gives Bob a grant allowing him to get any message in her DWN. + // Bob invokes that grant to get a message. - const messageReply = messagesGetReply.entry!; - expect(messageReply.messageCid).to.exist; - expect(messageReply.messageCid).to.equal(initialMessageCid); + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); - expect(messageReply.message).to.exist.and.not.be.undefined; - expect(messageReply.message?.data).to.be.undefined; + // Alice writes a record to her DWN + const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ + author: alice, + }); + const writeReply = await dwn.processMessage(alice.did, message, { dataStream }); + expect(writeReply.status.code).to.equal(202); + const messageCid = await Message.getCid(message); + + // Alice issues a permission grant allowing Bob to read any record in her DWN + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Get, + } + }); + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const grantReply = await dwn.processMessage(alice.did, permissionGrant.recordsWrite.message, { dataStream: grantDataStream }); + expect(grantReply.status.code).to.equal(202); + + // Bob invokes that grant to read a record from Alice's DWN + const messagesGet = await TestDataGenerator.generateMessagesGet({ + author : bob, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + messageCid, + }); + const readReply = await dwn.processMessage(alice.did, messagesGet.message); + expect(readReply.status.code).to.equal(200); + expect(readReply.entry).to.not.be.undefined; + expect(readReply.entry!.messageCid).to.equal(messageCid); }); - }); - it('returns a data stream if the data is larger than the encodedData threshold', async () => { - }); + describe('protocol scoped records', () => { + it('allows reads of protocol messages that are RecordsDelete', async () => { + // Scenario: Alice writes a protocol record. + // Alice deletes the record. + // Alice gives Bob a grant to read messages in the protocol. + // Bob invokes that grant to read the delete message. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = minimalProtocolDefinition; + + // Alice installs the protocol + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition, + }); + + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a record which will be deleted + const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'foo', + }); + const recordsWriteReply = await dwn.processMessage(alice.did, recordsWrite.message, { dataStream }); + expect(recordsWriteReply.status.code).to.equal(202); + + // Alice deletes the record + const recordsDelete = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : recordsWrite.message.recordId, + }); + const recordsDeleteReply = await dwn.processMessage(alice.did, recordsDelete.message); + expect(recordsDeleteReply.status.code).to.equal(202); + + // Alice grants Bob access to read messages in the protocol + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Get, + protocol : protocolDefinition.protocol, + } + }); + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const permissionGrantWriteReply = await dwn.processMessage( + alice.did, + permissionGrant.recordsWrite.message, + { dataStream: grantDataStream } + ); + expect(permissionGrantWriteReply.status.code).to.equal(202); + + // Bob is able to read the delete message + const deleteMessageCid = await Message.getCid(recordsDelete.message); + const messagesGet = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : deleteMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetReply = await dwn.processMessage(alice.did, messagesGet.message); + expect(messagesGetReply.status.code).to.equal(200); + expect(messagesGetReply.entry).to.exist; + expect(messagesGetReply.entry!.messageCid).to.equal(deleteMessageCid); + }); - it('does not return messages that belong to other tenants', async () => { - const alice = await TestDataGenerator.generateDidKeyPersona(); - const bob = await TestDataGenerator.generateDidKeyPersona(); + it('allows reads of protocol messages with a an unrestricted grant scope', async () => { + // scenario: Alice writes a protocol record. Alice gives Bob a grant to read any messages + // Bob invokes that grant to read the protocol messages. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = minimalProtocolDefinition; + + // Alice installs the protocol + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a record which Bob will later try to read + const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'foo', + }); + const recordsWriteReply = await dwn.processMessage(alice.did, recordsWrite.message, { dataStream }); + expect(recordsWriteReply.status.code).to.equal(202); + const recordMessageCid = await Message.getCid(recordsWrite.message); + + // Alice gives Bob a permission grant with scope MessagesGet + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Get, + } + }); + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const permissionGrantWriteReply = await dwn.processMessage( + alice.did, + permissionGrant.recordsWrite.message, + { dataStream: grantDataStream } + ); + expect(permissionGrantWriteReply.status.code).to.equal(202); + + // Bob is unable to get the message without using the permission grant + const messagesGetWithoutGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : recordMessageCid, + }); + const messagesGetWithoutGrantReply = await dwn.processMessage(alice.did, messagesGetWithoutGrant.message); + expect(messagesGetWithoutGrantReply.status.code).to.equal(401); + expect(messagesGetWithoutGrantReply.status.detail).to.contain(DwnErrorCode.MessagesGetAuthorizationFailed); + + // Bob is able to get the message when he uses the permission grant + const messagesGetWithGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : recordMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetWithGrantReply = await dwn.processMessage(alice.did, messagesGetWithGrant.message); + expect(messagesGetWithGrantReply.status.code).to.equal(200); + + // Bob is able to get the message of the grant associated with the protocol + const grantMessageCid = await Message.getCid(permissionGrant.recordsWrite.message); + const grantMessageRead = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : grantMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const grantMessageReadReply = await dwn.processMessage(alice.did, grantMessageRead.message); + expect(grantMessageReadReply.status.code).to.equal(200); + }); - const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ - author: alice - }); + it('allows reads of protocol messages with a protocol restricted grant scope', async () => { + // scenario: Alice writes a protocol record. Alice gives Bob a grant to read messages in the protocol + // Bob invokes that grant to read the protocol messages. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = minimalProtocolDefinition; + + // Alice installs the protocol + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a record which Bob will later try to read + const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'foo', + }); + const recordsWriteReply = await dwn.processMessage(alice.did, recordsWrite.message, { dataStream }); + expect(recordsWriteReply.status.code).to.equal(202); + const recordMessageCid = await Message.getCid(recordsWrite.message); + + // Alice gives Bob a permission grant with scope MessagesGet + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Get, + protocol : protocolDefinition.protocol, + } + }); + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const permissionGrantWriteReply = await dwn.processMessage( + alice.did, + permissionGrant.recordsWrite.message, + { dataStream: grantDataStream } + ); + expect(permissionGrantWriteReply.status.code).to.equal(202); + + // Bob is unable to get the message without using the permission grant + const messagesGetWithoutGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : recordMessageCid, + }); + const messagesGetWithoutGrantReply = await dwn.processMessage(alice.did, messagesGetWithoutGrant.message); + expect(messagesGetWithoutGrantReply.status.code).to.equal(401); + expect(messagesGetWithoutGrantReply.status.detail).to.contain(DwnErrorCode.MessagesGetAuthorizationFailed); + + // Bob is able to get the message when he uses the permission grant + const messagesGetWithGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : recordMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetWithGrantReply = await dwn.processMessage(alice.did, messagesGetWithGrant.message); + expect(messagesGetWithGrantReply.status.code).to.equal(200); + }); - const reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); - expect(reply.status.code).to.equal(202); + it('rejects message get of protocol messages with mismatching protocol grant scopes', async () => { + // scenario: Alice writes a protocol record. Alice gives Bob a grant to get messages from a different protocol + // Bob invokes that grant to get the protocol message, but fails. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = minimalProtocolDefinition; + + // Alice installs the protocol + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a record which Bob will later try to read + const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'foo', + }); + const recordsWriteReply = await dwn.processMessage(alice.did, recordsWrite.message, { dataStream }); + expect(recordsWriteReply.status.code).to.equal(202); + + // Alice gives Bob a permission grant with scope MessagesGet + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Get, + protocol : 'a-different-protocol' + } + }); + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const permissionGrantWriteReply = await dwn.processMessage( + alice.did, + permissionGrant.recordsWrite.message, + { dataStream: grantDataStream } + ); + expect(permissionGrantWriteReply.status.code).to.equal(202); + + // Bob is unable to read the record using the mismatched permission grant + const messagesGetWithoutGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : await Message.getCid(recordsWrite.message), + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetWithoutGrantReply = await dwn.processMessage(alice.did, messagesGetWithoutGrant.message); + expect(messagesGetWithoutGrantReply.status.code).to.equal(401); + expect(messagesGetWithoutGrantReply.status.detail).to.contain(DwnErrorCode.MessagesGetVerifyScopeFailed); + }); - const { message } = await TestDataGenerator.generateMessagesGet({ - author : bob, - messageCid : await Message.getCid(recordsWrite.message) + it('rejects message if the RecordsWrite message is not found for a RecordsDelete being retrieved', async () => { + // NOTE: This is a corner case that is unlikely to happen in practice, but is tested for completeness + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = minimalProtocolDefinition; + + // Alice installs the protocol + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition, + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice gives bob a grant to read messages in the protocol + const permissionGrant = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Get, + protocol : protocolDefinition.protocol, + } + }); + const grantDataStream = DataStream.fromBytes(permissionGrant.permissionGrantBytes); + const permissionGrantWriteReply = await dwn.processMessage( + alice.did, + permissionGrant.recordsWrite.message, + { dataStream: grantDataStream } + ); + expect(permissionGrantWriteReply.status.code).to.equal(202); + + // Alice creates the records write and records delete messages + const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'foo', + }); + + const { recordsDelete } = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : recordsWrite.message.recordId, + }); + + // Alice inserts the RecordsDelete message directly into the message store + const recordsDeleteCid = await Message.getCid(recordsDelete.message); + const indexes = recordsDelete.constructIndexes(recordsWrite.message); + await messageStore.put(alice.did, recordsDelete.message, indexes); + + // Bob tries to get the message + const messagesGet = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : recordsDeleteCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetReply = await dwn.processMessage(alice.did, messagesGet.message); + expect(messagesGetReply.status.code).to.equal(401); + expect(messagesGetReply.status.detail).to.contain(DwnErrorCode.MessagesGetWriteRecordNotFound); + }); }); - - // returns a 404 because the RecordsWrite created above is not bob's - const messagesGetReply: MessagesGetReply = await dwn.processMessage(bob.did, message); - expect(messagesGetReply.status.code).to.equal(404); }); }); } \ No newline at end of file diff --git a/tests/interfaces/messages-get.spec.ts b/tests/interfaces/messages-get.spec.ts index 7e4b88ef3..ce8b5fd9e 100644 --- a/tests/interfaces/messages-get.spec.ts +++ b/tests/interfaces/messages-get.spec.ts @@ -1,28 +1,30 @@ import type { MessagesGetMessage } from '../../src/index.js'; import { expect } from 'chai'; -import { Jws } from '../../src/index.js'; import { Message } from '../../src/core/message.js'; import { MessagesGet } from '../../src/index.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { DwnErrorCode, Jws } from '../../src/index.js'; describe('MessagesGet Message', () => { describe('create', () => { it('creates a MessagesGet message', async () => { const { author, message } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); + const messageTimestamp = TestDataGenerator.randomTimestamp(); const messagesGet = await MessagesGet.create({ signer : await Jws.createSigner(author), - messageCid : messageCid + messageCid : messageCid, + messageTimestamp, }); expect(messagesGet.message.authorization).to.exist; expect(messagesGet.message.descriptor).to.exist; expect(messagesGet.message.descriptor.messageCid).to.equal(messageCid); + expect(messagesGet.message.descriptor.messageTimestamp).to.equal(messageTimestamp); }); - it('throws an error if an invalid CID is provided', async () => { const alice = await TestDataGenerator.generatePersona(); @@ -34,7 +36,7 @@ describe('MessagesGet Message', () => { expect.fail(); } catch (e: any) { - expect(e.message).to.include('is not a valid CID'); + expect(e.message).to.include(DwnErrorCode.MessagesGetInvalidCid); } }); }); diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 755738676..3cb80f9a9 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -217,6 +217,7 @@ export type GenerateEventsSubscribeOutput = { export type GenerateMessagesGetInput = { author?: Persona; messageCid: string; + permissionGrantId?: string; }; export type GenerateMessagesGetOutput = { @@ -706,7 +707,8 @@ export class TestDataGenerator { const options: MessagesGetOptions = { signer, - messageCid: input.messageCid + messageCid : input.messageCid, + permissionGrantId : input.permissionGrantId }; const messagesGet = await MessagesGet.create(options);