From ab3ebf2d932c3086e3ab99593ca0c456eb2be04b Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 31 May 2024 14:58:38 -0400 Subject: [PATCH] messageget grant authorizations --- src/core/dwn-error.ts | 7 +- src/core/messages-grant-authorization.ts | 146 +++++++++++++++++++++++ src/handlers/messages-get.ts | 57 ++++++++- src/interfaces/messages-get.ts | 2 +- src/types/permission-types.ts | 4 + tests/handlers/messages-get.spec.ts | 54 +++------ 6 files changed, 230 insertions(+), 40 deletions(-) create mode 100644 src/core/messages-grant-authorization.ts diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index c846ec6ce..51e845740 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -44,7 +44,12 @@ export enum DwnErrorCode { IndexInvalidSortPropertyInMemory = 'IndexInvalidSortPropertyInMemory', IndexMissingIndexableProperty = 'IndexMissingIndexableProperty', JwsDecodePlainObjectPayloadInvalid = 'JwsDecodePlainObjectPayloadInvalid', - MessageGetInvalidCid = 'MessageGetInvalidCid', + MessagesGetInvalidCid = 'MessagesGetInvalidCid', + MessagesGetAuthorizationFailed ='MessagesGetAuthorizationFailed', + MessagesGetGrantNotFound = 'MessagesGetGrantNotFound', + MessagesGetWriteRecordNotFound = 'MessagesGetWriteRecordNotFound', + MessagesGetRecordsAuthorizationFailed ='MessagesGetRecordsAuthorizationFailed', + MessagesGetGrantProtocolScopeAuthorizationFailed ='MessagesGetGrantProtocolScopeAuthorizationFailed', ParseCidCodecNotSupported = 'ParseCidCodecNotSupported', ParseCidMultihashNotSupported = 'ParseCidMultihashNotSupported', PermissionsProtocolValidateSchemaUnexpectedRecord = 'PermissionsProtocolValidateSchemaUnexpectedRecord', diff --git a/src/core/messages-grant-authorization.ts b/src/core/messages-grant-authorization.ts new file mode 100644 index 000000000..ed1292e00 --- /dev/null +++ b/src/core/messages-grant-authorization.ts @@ -0,0 +1,146 @@ +import type { GenericMessage } from '../types/message-types.js'; +import type { MessagesGet } from '../interfaces/messages-get.js'; +import type { MessagesGetMessage } from '../types/messages-types.js'; +import type { MessageStore } from '../types/message-store.js'; +import type { MessagesGetPermissionScope, RecordsPermissionScope } from '../types/permission-types.js'; +import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; + +import { GrantAuthorization } from './grant-authorization.js'; +import { Message } from './message.js'; +import { PermissionGrant } from '../protocols/permission-grant.js'; +import { PermissionsProtocol } from '../protocols/permissions.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 MessagesGetPermissionScope; + await MessagesGrantAuthorization.verifyScope(expectedGrantor, messageToGet, scope, messageStore); + } + + public static async authorizeMessagesGetRecords(input: { + tenant: string, + incomingMessage: MessagesGet, + recordMessage: RecordsWriteMessage | RecordsDeleteMessage, + messageStore: MessageStore, + }): Promise { + const { tenant, incomingMessage, recordMessage, messageStore } = input; + const recordsWriteToAuthorize = await this.getRecordsWriteMessageToAuthorize(tenant, recordMessage, messageStore); + + const { descriptor } = recordsWriteToAuthorize; + if (descriptor.published === true) { + // authentication is not required for published data + return; + } else if (incomingMessage.author !== undefined && incomingMessage.author === descriptor.recipient) { + // The recipient of a message may always read it + return; + } else { + // if we reached here, the record is not authorized + throw new DwnError(DwnErrorCode.MessagesGetRecordsAuthorizationFailed, 'record message failed authorization'); + } + } + + /** + * Verifies the given record against the scope of the given grant. + */ + private static async verifyScope( + tenant: string, + message: GenericMessage, + incomingScope: MessagesGetPermissionScope, + messageStore: MessageStore, + ): Promise { + if (incomingScope.protocol === undefined) { + // no protocol to verify, the grant has access to all records + return; + } + + // Only protocol scopes are currently supported for Messages interface authorizations + if (message.descriptor.interface === DwnInterfaceName.Records) { + const recordMessage = message as RecordsWriteMessage | RecordsDeleteMessage; + const recordsWriteToAuthorize = await this.getRecordsWriteMessageToAuthorize(tenant, recordMessage, messageStore); + const { protocol: recordProtocol } = recordsWriteToAuthorize.descriptor; + if (recordProtocol === PermissionsProtocol.uri) { + // the incoming message is a grant or revocation message + // we need to get the protocol associated with the underlying grant + + let grantMessage: RecordsWriteMessage | undefined; + if (recordsWriteToAuthorize.descriptor.protocolPath === PermissionsProtocol.revocationPath) { + // fetch the grant associated with the revocation + const permissionGrantId = recordsWriteToAuthorize.descriptor.parentId!; + const grantAuthorizedMessagesQuery = { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.grantPath, + recordId : permissionGrantId, + }; + const { messages: grantMessages } = await messageStore.query(tenant, [ grantAuthorizedMessagesQuery ]); + grantMessage = await Message.getNewestMessage(grantMessages) as RecordsWriteMessage | undefined; + } else { + grantMessage = recordsWriteToAuthorize; + } + + const grantMessageScope = grantMessage ? (await PermissionGrant.parse(grantMessage)).scope as RecordsPermissionScope : undefined; + if (grantMessageScope && grantMessageScope.protocol === incomingScope.protocol) { + // the record grant message has the same scope protocol as the incoming scope protocol + return; + } + } else {recordProtocol === incomingScope.protocol;} { + // the record protocol matches the incoming scope protocol + return; + } + } + + throw new DwnError(DwnErrorCode.MessagesGetGrantProtocolScopeAuthorizationFailed, 'record message failed protocol authorization'); + } + + private static async getRecordsWriteMessageToAuthorize( + tenant: string, + message: RecordsWriteMessage | RecordsDeleteMessage, + messageStore: MessageStore + ): Promise { + if (message.descriptor.method === DwnMethodName.Write) { + return message as RecordsWriteMessage; + } else { + // it is a RecordsDeleteMessage, need to fetch the latest RecordWrite to verify the authorization + // get existing RecordsWrite messages matching the `recordId` + const query = { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + recordId : (message as RecordsDeleteMessage).descriptor.recordId + }; + + const { messages: existingMessages } = await messageStore.query(tenant, [ query ]); + const newestWrite = await Message.getNewestMessage(existingMessages); + if (newestWrite !== undefined) { + return newestWrite as RecordsWriteMessage; + } + } + + 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..9a3d85745 100644 --- a/src/handlers/messages-get.ts +++ b/src/handlers/messages-get.ts @@ -1,16 +1,22 @@ -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 { DataEncodedRecordsWriteMessage, RecordsDeleteMessage, RecordsWriteMessage } 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 { DwnInterfaceName } from '../enums/dwn-interface-method.js'; import { Encoder } from '../utils/encoder.js'; +import { Message } from '../core/message.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,7 +34,6 @@ export class MessagesGetHandler implements MethodHandler { try { await authenticate(message.authorization, this.didResolver); - await authorizeOwner(tenant, messagesGet); } catch (e) { return messageReplyFromError(e, 401); } @@ -39,6 +44,12 @@ export class MessagesGetHandler implements MethodHandler { 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 @@ -66,4 +77,44 @@ 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 (Message.isSignedByAuthorDelegate(matchedMessage)) { + throw new Error('not implemented.'); + } + + 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 if (matchedMessage.descriptor.interface === DwnInterfaceName.Records) { + // If the message is not a grant and the interface is Records, we need to authorize the Records message + await MessagesGrantAuthorization.authorizeMessagesGetRecords({ + tenant, + incomingMessage : messagesGet, + recordMessage : matchedMessage as RecordsWriteMessage | RecordsDeleteMessage, + messageStore + }); + } else { + throw new DwnError(DwnErrorCode.MessagesGetAuthorizationFailed, '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..b24d25701 100644 --- a/src/interfaces/messages-get.ts +++ b/src/interfaces/messages-get.ts @@ -51,7 +51,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/permission-types.ts b/src/types/permission-types.ts index 37c8e1d04..7d18c375c 100644 --- a/src/types/permission-types.ts +++ b/src/types/permission-types.ts @@ -89,6 +89,10 @@ export type RecordsPermissionScope = { schema?: string; }; +export type MessagesGetPermissionScope = { + protocol?: string; +}; + export enum PermissionConditionPublication { Required = 'Required', Prohibited = 'Prohibited', diff --git a/tests/handlers/messages-get.spec.ts b/tests/handlers/messages-get.spec.ts index 7338d5da9..a1008a9bb 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -56,13 +56,18 @@ export function testMessagesGetHandler(): void { it('returns a 401 if tenant is not author', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); const bob = await TestDataGenerator.generateDidKeyPersona(); - const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author: alice }); + + // bob creates a record and writes it to his DWN + const { message: recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: bob }); + const { status } = await dwn.processMessage(bob.did, recordsWrite, { dataStream }); + expect(status.code).to.equal(202); const { message } = await TestDataGenerator.generateMessagesGet({ author : alice, - messageCid : await Message.getCid(recordsWrite.message) + messageCid : await Message.getCid(recordsWrite) }); + // alice is not the author of the message const reply = await dwn.processMessage(bob.did, message); expect(reply.status.code).to.equal(401); @@ -117,19 +122,22 @@ export function testMessagesGetHandler(): void { expect(reply.status.code).to.equal(404); }); + it('returns a data stream if the data is larger than the encodedData threshold', async () => { + }); + 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({ + const { message: recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ author : alice, data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded), }); - const reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); + const reply = await dwn.processMessage(alice.did, recordsWrite, { dataStream }); expect(reply.status.code).to.equal(202); - const recordsWriteMessageCid = await Message.getCid(recordsWrite.message); + const recordsWriteMessageCid = await Message.getCid(recordsWrite); const { message } = await TestDataGenerator.generateMessagesGet({ author : alice, messageCid : recordsWriteMessageCid @@ -152,15 +160,15 @@ export function testMessagesGetHandler(): void { it('data is greater than threshold', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); - const { recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ + const { message: recordsWrite, dataStream, dataBytes } = await TestDataGenerator.generateRecordsWrite({ author : alice, data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), }); - const reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); + const reply = await dwn.processMessage(alice.did, recordsWrite, { dataStream }); expect(reply.status.code).to.equal(202); - const recordsWriteMessageCid = await Message.getCid(recordsWrite.message); + const recordsWriteMessageCid = await Message.getCid(recordsWrite); const { message } = await TestDataGenerator.generateMessagesGet({ author : alice, messageCid : recordsWriteMessageCid @@ -184,14 +192,14 @@ export function testMessagesGetHandler(): void { const alice = await TestDataGenerator.generateDidKeyPersona(); // initial write - const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ + const { message: recordsWriteMessage, recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ author : alice, data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 10), }); - const initialMessageCid = await Message.getCid(recordsWrite.message); + const initialMessageCid = await Message.getCid(recordsWriteMessage); - let reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); + let reply = await dwn.processMessage(alice.did, recordsWriteMessage, { dataStream }); expect(reply.status.code).to.equal(202); const { recordsWrite: updateMessage, dataStream: updateDataStream } = await TestDataGenerator.generateFromRecordsWrite({ @@ -220,29 +228,5 @@ export function testMessagesGetHandler(): void { expect(messageReply.message?.data).to.be.undefined; }); }); - - it('returns a data stream if the data is larger than the encodedData threshold', async () => { - }); - - it('does not return messages that belong to other tenants', async () => { - const alice = await TestDataGenerator.generateDidKeyPersona(); - const bob = await TestDataGenerator.generateDidKeyPersona(); - - const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ - author: alice - }); - - const reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); - expect(reply.status.code).to.equal(202); - - const { message } = await TestDataGenerator.generateMessagesGet({ - author : bob, - messageCid : await Message.getCid(recordsWrite.message) - }); - - // 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