From 592597a311ca5f8cb0205c6651413086ba94e95a Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 25 Jun 2024 22:05:38 -0400 Subject: [PATCH] Permissions Grants for `MessagesGet` (#748) This PR modifies `MessagesGet` to better suit it's use and support permissions. - MessagesGet now only supports getting a single message - If a message has data, it will be provided via a `data` method similarly to `ReocrdsRead` - Can Grant Permissions for MessagesGet scoped - Allows unrestricted scope (no protocol scope spcified) - If scoped to a protocol and the message to get is a Permissions-Protocol record, it will match against the scope. - Delete messages are matched against the protocol of the `RecordsWrite` they reference --- .../interface-methods/messages-get.json | 8 +- .../permissions/permissions-definitions.json | 3 + json-schemas/permissions/scopes.json | 19 + src/core/dwn-error.ts | 16 +- src/core/messages-grant-authorization.ts | 97 ++ src/handlers/messages-get.ts | 95 +- src/interfaces/messages-get.ts | 34 +- src/interfaces/records-write.ts | 20 + src/protocols/permissions.ts | 43 +- src/types/messages-types.ts | 9 +- src/types/permission-types.ts | 1 + tests/handlers/messages-get.spec.ts | 848 ++++++++++++++---- tests/interfaces/messages-get.spec.ts | 45 +- tests/protocols/permissions.spec.ts | 170 ++++ tests/utils/test-data-generator.ts | 6 +- 15 files changed, 1149 insertions(+), 265 deletions(-) create mode 100644 src/core/messages-grant-authorization.ts create mode 100644 tests/protocols/permissions.spec.ts diff --git a/json-schemas/interface-methods/messages-get.json b/json-schemas/interface-methods/messages-get.json index 5fa1a2495..365491b7a 100644 --- a/json-schemas/interface-methods/messages-get.json +++ b/json-schemas/interface-methods/messages-get.json @@ -35,12 +35,8 @@ "messageTimestamp": { "$ref": "https://identity.foundation/dwn/json-schemas/defs.json#/$defs/date-time" }, - "messageCids": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 + "messageCid": { + "type": "string" } } } diff --git a/json-schemas/permissions/permissions-definitions.json b/json-schemas/permissions/permissions-definitions.json index 595ccc0d4..34142904b 100644 --- a/json-schemas/permissions/permissions-definitions.json +++ b/json-schemas/permissions/permissions-definitions.json @@ -11,6 +11,9 @@ { "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/$defs/events-subscribe-scope" }, + { + "$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 be06f5580..44c1d2d3a 100644 --- a/json-schemas/permissions/scopes.json +++ b/json-schemas/permissions/scopes.json @@ -41,6 +41,25 @@ } } }, + "messages-get-scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "interface", + "method" + ], + "properties": { + "interface": { + "const": "Messages" + }, + "method": { + "const": "Get" + }, + "protocol": { + "type": "string" + } + } + }, "protocols-query-scope": { "type": "object", "additionalProperties": false, diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 869b07cfb..1eecad43f 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -20,10 +20,6 @@ export enum DwnErrorCode { AuthorizationNotGrantedToAuthor = 'AuthorizationNotGrantedToAuthor', ComputeCidCodecNotSupported = 'ComputeCidCodecNotSupported', ComputeCidMultihashNotSupported = 'ComputeCidMultihashNotSupported', - DidMethodNotSupported = 'DidMethodNotSupported', - DidNotString = 'DidNotString', - DidNotValid = 'DidNotValid', - DidResolutionFailed = 'DidResolutionFailed', Ed25519InvalidJwk = 'Ed25519InvalidJwk', EventEmitterStreamNotOpenError = 'EventEmitterStreamNotOpenError', EventsGrantAuthorizationMismatchedProtocol = 'EventsGrantAuthorizationMismatchedProtocol', @@ -47,11 +43,14 @@ export enum DwnErrorCode { IndexInvalidSortPropertyInMemory = 'IndexInvalidSortPropertyInMemory', IndexMissingIndexableProperty = 'IndexMissingIndexableProperty', JwsDecodePlainObjectPayloadInvalid = 'JwsDecodePlainObjectPayloadInvalid', - MessageGetInvalidCid = 'MessageGetInvalidCid', + MessagesGetInvalidCid = 'MessagesGetInvalidCid', + MessagesGetAuthorizationFailed = 'MessagesGetAuthorizationFailed', + MessagesGetVerifyScopeFailed = 'MessagesGetVerifyScopeFailed', ParseCidCodecNotSupported = 'ParseCidCodecNotSupported', ParseCidMultihashNotSupported = 'ParseCidMultihashNotSupported', PermissionsProtocolCreateGrantRecordsScopeMissingProtocol = 'PermissionsProtocolCreateGrantRecordsScopeMissingProtocol', PermissionsProtocolCreateRequestRecordsScopeMissingProtocol = 'PermissionsProtocolCreateRequestRecordsScopeMissingProtocol', + PermissionsProtocolGetScopeInvalidProtocol = 'PermissionsProtocolGetScopeInvalidProtocol', PermissionsProtocolValidateSchemaUnexpectedRecord = 'PermissionsProtocolValidateSchemaUnexpectedRecord', PermissionsProtocolValidateScopeContextIdProhibitedProperties = 'PermissionsProtocolValidateScopeContextIdProhibitedProperties', PermissionsProtocolValidateScopeProtocolMismatch = 'PermissionsProtocolValidateScopeProtocolMismatch', @@ -77,7 +76,6 @@ export enum DwnErrorCode { ProtocolAuthorizationNotARole = 'ProtocolAuthorizationNotARole', ProtocolAuthorizationParentNotFoundConstructingRecordChain = 'ProtocolAuthorizationParentNotFoundConstructingRecordChain', ProtocolAuthorizationProtocolNotFound = 'ProtocolAuthorizationProtocolNotFound', - ProtocolAuthorizationQueryWithoutRole = 'ProtocolAuthorizationQueryWithoutRole', ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient', ProtocolAuthorizationTagsInvalidSchema = 'ProtocolAuthorizationTagsInvalidSchema', ProtocolsConfigureDuplicateActorInRuleSet = 'ProtocolsConfigureDuplicateActorInRuleSet', @@ -90,10 +88,8 @@ export enum DwnErrorCode { ProtocolsConfigureInvalidRecipientOfAction = 'ProtocolsConfigureInvalidRecipientOfAction', ProtocolsConfigureInvalidRuleSetRecordType = 'ProtocolsConfigureInvalidRuleSetRecordType', ProtocolsConfigureInvalidTagSchema = 'ProtocolsConfigureInvalidTagSchema', - ProtocolsConfigureQueryNotAllowed = 'ProtocolsConfigureQueryNotAllowed', ProtocolsConfigureRecordNestingDepthExceeded = 'ProtocolsConfigureRecordNestingDepthExceeded', ProtocolsConfigureRoleDoesNotExistAtGivenPath = 'ProtocolsConfigureRoleDoesNotExistAtGivenPath', - ProtocolsConfigureUnauthorized = 'ProtocolsConfigureUnauthorized', ProtocolsQueryUnauthorized = 'ProtocolsQueryUnauthorized', RecordsAuthorDelegatedGrantAndIdExistenceMismatch = 'RecordsAuthorDelegatedGrantAndIdExistenceMismatch', RecordsAuthorDelegatedGrantCidMismatch = 'RecordsAuthorDelegatedGrantCidMismatch', @@ -108,10 +104,8 @@ export enum DwnErrorCode { RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch', RecordsGrantAuthorizationQueryOrSubscribeProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryOrSubscribeProtocolScopeMismatch', RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch', - RecordsGrantAuthorizationScopeNotRecords = `RecordsGrantAuthorizationScopeNotRecords`, RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch', RecordsGrantAuthorizationScopeProtocolPathMismatch = 'RecordsGrantAuthorizationScopeProtocolPathMismatch', - RecordsGrantAuthorizationScopeSchema = 'RecordsGrantAuthorizationScopeSchema', RecordsDerivePrivateKeyUnSupportedCurve = 'RecordsDerivePrivateKeyUnSupportedCurve', RecordsInvalidAncestorKeyDerivationSegment = 'RecordsInvalidAncestorKeyDerivationSegment', RecordsOwnerDelegatedGrantAndIdExistenceMismatch = 'RecordsOwnerDelegatedGrantAndIdExistenceMismatch', @@ -137,6 +131,7 @@ export enum DwnErrorCode { RecordsWriteDataCidMismatch = 'RecordsWriteDataCidMismatch', RecordsWriteDataSizeMismatch = 'RecordsWriteDataSizeMismatch', RecordsWriteGetEntryIdUndefinedAuthor = 'RecordsWriteGetEntryIdUndefinedAuthor', + RecordsWriteGetNewestWriteRecordNotFound = 'RecordsWriteGetNewestWriteRecordNotFound', RecordsWriteGetInitialWriteNotFound = 'RecordsWriteGetInitialWriteNotFound', RecordsWriteImmutablePropertyChanged = 'RecordsWriteImmutablePropertyChanged', RecordsWriteMissingSigner = 'RecordsWriteMissingSigner', @@ -164,5 +159,4 @@ export enum DwnErrorCode { UrlProtocolNotNormalized = 'UrlProtocolNotNormalized', UrlProtocolNotNormalizable = 'UrlProtocolNotNormalizable', UrlSchemaNotNormalized = 'UrlSchemaNotNormalized', - UrlSchemaNotNormalizable = 'UrlSchemaNotNormalizable', }; diff --git a/src/core/messages-grant-authorization.ts b/src/core/messages-grant-authorization.ts new file mode 100644 index 000000000..2e1434155 --- /dev/null +++ b/src/core/messages-grant-authorization.ts @@ -0,0 +1,97 @@ +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 { ProtocolsConfigureMessage } from '../types/protocols-types.js'; +import type { DataEncodedRecordsWriteMessage, RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; + +import { DwnInterfaceName } from '../enums/dwn-interface-method.js'; +import { GrantAuthorization } from './grant-authorization.js'; +import { PermissionsProtocol } from '../protocols/permissions.js'; +import { Records } from '../utils/records.js'; +import { RecordsWrite } from '../interfaces/records-write.js'; +import { DwnError, DwnErrorCode } from './dwn-error.js'; + +export class MessagesGrantAuthorization { + + /** + * Authorizes a MessagesGetMessage using the given permission grant. + * @param messageStore Used to check if the given grant has been revoked; and to fetch related RecordsWrites if needed. + */ + public static async authorizeMessagesGet(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) { + // if the message is a Records interface message, get the RecordsWrite message associated with the record + const recordsMessage = messageToGet as RecordsWriteMessage | RecordsDeleteMessage; + const recordsWriteMessage = Records.isRecordsWrite(recordsMessage) ? recordsMessage : + await RecordsWrite.fetchNewestRecordsWrite(messageStore, tenant, recordsMessage.descriptor.recordId); + + if (recordsWriteMessage.descriptor.protocol === incomingScope.protocol) { + // the record protocol matches the incoming scope protocol + return; + } + + // we check if the protocol is the internal PermissionsProtocol for further validation + if (recordsWriteMessage.descriptor.protocol === PermissionsProtocol.uri) { + // get the permission scope from the permission message + const permissionScope = await PermissionsProtocol.getScopeFromPermissionRecord( + tenant, + messageStore, + recordsWriteMessage as DataEncodedRecordsWriteMessage + ); + + if (PermissionsProtocol.hasProtocolScope(permissionScope) && permissionScope.protocol === incomingScope.protocol) { + // the permissions record scoped protocol matches the incoming scope protocol + return; + } + } + } else if (messageToGet.descriptor.interface === DwnInterfaceName.Protocols) { + // if the message is a protocol message, it must be a `ProtocolConfigure` message + const protocolsConfigureMessage = messageToGet as ProtocolsConfigureMessage; + const configureProtocol = protocolsConfigureMessage.descriptor.definition.protocol; + if (configureProtocol === incomingScope.protocol) { + // the configured protocol matches the incoming scope protocol + return; + } + } + + throw new DwnError(DwnErrorCode.MessagesGetVerifyScopeFailed, 'record message failed scope authorization'); + } +} \ No newline at end of file diff --git a/src/handlers/messages-get.ts b/src/handlers/messages-get.ts index b91c2eff8..af8c9a2ca 100644 --- a/src/handlers/messages-get.ts +++ b/src/handlers/messages-get.ts @@ -1,14 +1,20 @@ 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 { authenticate, authorizeOwner } from '../core/auth.js'; -import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; +import { MessagesGrantAuthorization } from '../core/messages-grant-authorization.js'; +import { PermissionsProtocol } from '../protocols/permissions.js'; +import { Records } from '../utils/records.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; type HandleArgs = { tenant: string, message: MessagesGetMessage }; @@ -26,55 +32,72 @@ export class MessagesGetHandler implements MethodHandler { try { await authenticate(message.authorization, this.didResolver); - await authorizeOwner(tenant, messagesGet); } catch (e) { return messageReplyFromError(e, 401); } - const promises: Promise[] = []; - const messageCids = new Set(message.descriptor.messageCids); - - for (const messageCid of messageCids) { - const promise = this.messageStore.get(tenant, messageCid) - .then(message => { - return { messageCid, message }; - }) - .catch(_ => { - return { messageCid, message: undefined, error: `Failed to get message ${messageCid}` }; - }); - - promises.push(promise); + const messageResult = await this.messageStore.get(tenant, message.descriptor.messageCid); + if (messageResult === undefined) { + return { status: { code: 404, detail: 'Not Found' } }; } - const messages = await Promise.all(promises); - - // for every message, include associated data as `encodedData` IF: - // * its a RecordsWrite - // * the data size is equal or smaller than the size threshold - for (const entry of messages) { - const { message } = entry; - - if (!message) { - continue; - } - - const { interface: messageInterface, method } = message.descriptor; - if (messageInterface !== DwnInterfaceName.Records || method !== DwnMethodName.Write) { - continue; - } + try { + await MessagesGetHandler.authorizeMessagesGet(tenant, messagesGet, messageResult, this.messageStore); + } catch (error) { + return messageReplyFromError(error, 401); + } + // If the message is a RecordsWrite, we include the data in the response if it is available + const entry: MessagesGetReplyEntry = { message: messageResult, messageCid: message.descriptor.messageCid }; + 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. - const recordsWrite = message as RecordsQueryReplyEntry; if (recordsWrite.encodedData !== undefined) { - entry.encodedData = recordsWrite.encodedData; + const dataBytes = Encoder.base64UrlToBytes(recordsWrite.encodedData); + entry.message.data = DataStream.fromBytes(dataBytes); delete recordsWrite.encodedData; + } else { + // otherwise 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; + } } } return { - status : { code: 200, detail: 'OK' }, - entries : messages + status: { code: 200, detail: 'OK' }, + entry }; } + + /** + * @param messageStore Used to fetch related permission grant, permission revocation, and/or RecordsWrites for permission scope validation. + */ + 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; + } else 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.authorizeMessagesGet({ + 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 9cd6c479d..c75115052 100644 --- a/src/interfaces/messages-get.ts +++ b/src/interfaces/messages-get.ts @@ -9,15 +9,16 @@ import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export type MessagesGetOptions = { - messageCids: string[]; + messageCid: string; signer: Signer; messageTimestamp?: string; + permissionGrantId?: string; }; export class MessagesGet extends AbstractMessage { public static async parse(message: MessagesGetMessage): Promise { Message.validateJsonSchema(message); - this.validateMessageCids(message.descriptor.messageCids); + this.validateMessageCid(message.descriptor.messageCid); await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); Time.validateTimestamp(message.descriptor.messageTimestamp); @@ -29,31 +30,34 @@ export class MessagesGet extends AbstractMessage { const descriptor: MessagesGetDescriptor = { interface : DwnInterfaceName.Messages, method : DwnMethodName.Get, - messageCids : options.messageCids, - messageTimestamp : options?.messageTimestamp ?? Time.getCurrentTimestamp(), + messageCid : options.messageCid, + 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); - MessagesGet.validateMessageCids(options.messageCids); + MessagesGet.validateMessageCid(options.messageCid); return new MessagesGet(message); } /** - * validates the provided cids - * @param messageCids - the cids in question + * validates the provided cid + * @param messageCid - the cid in question * @throws {DwnError} if an invalid cid is found. */ - private static validateMessageCids(messageCids: string[]): void { - for (const cid of messageCids) { - try { - Cid.parseCid(cid); - } catch (_) { - throw new DwnError(DwnErrorCode.MessageGetInvalidCid, `${cid} is not a valid CID`); - } + private static validateMessageCid(messageCid: string): void { + try { + Cid.parseCid(messageCid); + } catch (_) { + throw new DwnError(DwnErrorCode.MessagesGetInvalidCid, `${messageCid} is not a valid CID`); } } } \ No newline at end of file diff --git a/src/interfaces/records-write.ts b/src/interfaces/records-write.ts index c1fcb558f..584daaf98 100644 --- a/src/interfaces/records-write.ts +++ b/src/interfaces/records-write.ts @@ -1011,6 +1011,26 @@ export class RecordsWrite implements MessageInterface { return attesters; } + public static async fetchNewestRecordsWrite( + messageStore: MessageStore, + tenant: string, + recordId: string, + ): Promise { + // get existing RecordsWrite messages matching the `recordId` + const query = { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + recordId : 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.RecordsWriteGetNewestWriteRecordNotFound, 'record not found'); + } /** * Fetches the initial RecordsWrite of a record. diff --git a/src/protocols/permissions.ts b/src/protocols/permissions.ts index 34fc6d9b0..82477c951 100644 --- a/src/protocols/permissions.ts +++ b/src/protocols/permissions.ts @@ -7,6 +7,7 @@ import type { PermissionConditions, PermissionGrantData, PermissionRequestData, import { Encoder } from '../utils/encoder.js'; import { PermissionGrant } from './permission-grant.js'; +import { PermissionRequest } from './permission-request.js'; import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { Time } from '../utils/time.js'; import { validateJsonSchema } from '../schema-validator.js'; @@ -287,7 +288,8 @@ export class PermissionsProtocol { public static async createRevocation(options: PermissionRevocationCreateOptions): Promise<{ recordsWrite: RecordsWrite, permissionRevocationData: PermissionRevocationData, - permissionRevocationBytes: Uint8Array + permissionRevocationBytes: Uint8Array, + dataEncodedMessage: DataEncodedRecordsWriteMessage, }> { const permissionRevocationData: PermissionRevocationData = { description: options.description, @@ -316,10 +318,16 @@ export class PermissionsProtocol { tags : permissionTags, }); + const dataEncodedMessage: DataEncodedRecordsWriteMessage = { + ...recordsWrite.message, + encodedData: Encoder.bytesToBase64Url(permissionRevocationBytes) + }; + return { recordsWrite, permissionRevocationData, - permissionRevocationBytes + permissionRevocationBytes, + dataEncodedMessage }; } @@ -391,6 +399,37 @@ export class PermissionsProtocol { return permissionGrant; } + /** + * Gets the scope from the given permission record. + * If the record is a revocation, the scope is fetched from the grant that is being revoked. + * + * @param messageStore The message store to fetch the grant for a revocation. + */ + public static async getScopeFromPermissionRecord( + tenant: string, + messageStore:MessageStore, + incomingMessage: DataEncodedRecordsWriteMessage, + ): Promise { + if (incomingMessage.descriptor.protocol !== PermissionsProtocol.uri) { + throw new DwnError( + DwnErrorCode.PermissionsProtocolGetScopeInvalidProtocol, + `Unexpected protocol for permission record: ${incomingMessage.descriptor.protocol}` + ); + } + + if (incomingMessage.descriptor.protocolPath === PermissionsProtocol.revocationPath) { + const grant = await PermissionsProtocol.fetchGrant(tenant, messageStore, incomingMessage.descriptor.parentId!); + return grant.scope; + } else if (incomingMessage.descriptor.protocolPath === PermissionsProtocol.grantPath) { + const grant = await PermissionGrant.parse(incomingMessage); + return grant.scope; + } else { + // if the record is not a grant or revocation, it must be a request + const request = await PermissionRequest.parse(incomingMessage); + return request.scope; + } + } + /** * Normalizes the given permission scope if needed. * @returns The normalized permission scope. diff --git a/src/types/messages-types.ts b/src/types/messages-types.ts index 0a2aa7712..a36894ff4 100644 --- a/src/types/messages-types.ts +++ b/src/types/messages-types.ts @@ -1,10 +1,11 @@ +import type { Readable } from 'readable-stream'; import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export type MessagesGetDescriptor = { interface : DwnInterfaceName.Messages; method: DwnMethodName.Get; - messageCids: string[]; + messageCid: string; messageTimestamp: string; }; @@ -15,11 +16,9 @@ export type MessagesGetMessage = GenericMessage & { export type MessagesGetReplyEntry = { messageCid: string; - message?: GenericMessage; - encodedData?: string; - error?: string; + message: (GenericMessage & { data?: Readable }); }; export type MessagesGetReply = GenericMessageReply & { - entries?: MessagesGetReplyEntry[]; + entry?: MessagesGetReplyEntry; }; diff --git a/src/types/permission-types.ts b/src/types/permission-types.ts index 15c820a58..ea6750e17 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 17238e3b9..41339a91a 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -9,14 +9,14 @@ 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 { MessagesGetHandler } from '../../src/handlers/messages-get.js'; -import { stubInterface } from 'ts-sinon'; +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, DwnErrorCode, DwnInterfaceName, DwnMethodName, Jws, PermissionGrant, PermissionsProtocol, Time } from '../../src/index.js'; import { DidKey, UniversalResolver } from '@web5/dids'; -import { Dwn, DwnConstant } from '../../src/index.js'; import sinon from 'sinon'; @@ -56,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 a 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, - messageCids : [await Message.getCid(recordsWrite.message)] + author : alice, + 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 () => { @@ -79,8 +82,8 @@ export function testMessagesGetHandler(): void { const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author: alice }); const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCids : [await Message.getCid(recordsWrite.message)] + author : alice, + messageCid : await Message.getCid(recordsWrite.message) }); (message['descriptor'] as any)['troll'] = 'hehe'; @@ -95,180 +98,709 @@ export function testMessagesGetHandler(): void { const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author: alice }); const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCids : [await Message.getCid(recordsWrite.message)] + author : alice, + messageCid : await Message.getCid(recordsWrite.message) }); - message.descriptor.messageCids = ['hehetroll']; + message.descriptor.messageCid = 'hehetroll'; const reply: MessagesGetReply = await dwn.processMessage(alice.did, message); expect(reply.status.code).to.equal(400); expect(reply.status.detail).to.include('is not a valid CID'); - expect(reply.entries).to.be.undefined; + expect(reply.entry).to.be.undefined; }); - it('returns all requested messages', async () => { - const did = await TestDataGenerator.generateDidKeyPersona(); - const alice = await TestDataGenerator.generatePersona(did); - const messageCids: string[] = []; - - const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ - author: alice - }); - - let messageCid = await Message.getCid(recordsWrite.message); - messageCids.push(messageCid); - - let reply = await dwn.processMessage(alice.did, recordsWrite.toJSON(), { dataStream }); - expect(reply.status.code).to.equal(202); - - const { recordsDelete } = await TestDataGenerator.generateRecordsDelete({ - author : alice, - recordId : recordsWrite.message.recordId - }); - - messageCid = await Message.getCid(recordsDelete.message); - messageCids.push(messageCid); - - reply = await dwn.processMessage(alice.did, recordsDelete.toJSON()); - expect(reply.status.code).to.equal(202); - - const { protocolsConfigure } = await TestDataGenerator.generateProtocolsConfigure({ - author: alice - }); - - messageCid = await Message.getCid(protocolsConfigure.message); - messageCids.push(messageCid); - - reply = await dwn.processMessage(alice.did, protocolsConfigure.toJSON()); - expect(reply.status.code).to.equal(202); - - const { message } = await TestDataGenerator.generateMessagesGet({ - author: alice, - messageCids - }); - - const messagesGetReply = await dwn.processMessage(alice.did, message); - expect(messagesGetReply.status.code).to.equal(200); - expect(messagesGetReply.entries!.length).to.equal(messageCids.length); - - for (const messageReply of messagesGetReply.entries!) { - expect(messageReply.messageCid).to.not.be.undefined; - expect(messageReply.message).to.not.be.undefined; - expect(messageCids).to.include(messageReply.messageCid); - - const cid = await Message.getCid(messageReply.message!); - expect(messageReply.messageCid).to.equal(cid); - } - }); - - 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); const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCids : [recordsWriteMessageCid] + author : alice, + messageCid : recordsWriteMessageCid }); - // 0 messages expected because the RecordsWrite created above was never stored + // 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(200); - expect(reply.entries!.length).to.equal(1); - - for (const messageReply of reply.entries!) { - expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); - expect(messageReply.message).to.be.undefined; - } - }); - - it('returns an error message for a specific cid if getting that message from the MessageStore fails', async () => { - // stub the messageStore.get call to throw an error - const messageStore = stubInterface(); - messageStore.get.rejects('internal db error'); - - const dataStore = stubInterface(); - - const messagesGetHandler = new MessagesGetHandler(didResolver, messageStore, dataStore); - - const alice = await TestDataGenerator.generateDidKeyPersona(); - const { recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const recordsWriteMessageCid = await Message.getCid(recordsWrite.message); - - const { message } = await TestDataGenerator.generateMessagesGet({ - author : alice, - messageCids : [recordsWriteMessageCid] - }); - - const reply = await messagesGetHandler.handle({ tenant: alice.did, message }); - - expect(messageStore.get.called).to.be.true; - - expect(reply.status.code).to.equal(200); - expect(reply.entries!.length).to.equal(1); - expect(reply.entries![0].error).to.exist; - expect(reply.entries![0].error).to.include(`Failed to get message ${recordsWriteMessageCid}`); - expect(reply.entries![0].message).to.be.undefined; + expect(reply.status.code).to.equal(404); + expect(reply.entry).to.be.undefined; }); - it('includes encodedData in reply entry if the data is available and dataSize < threshold', async () => { - const alice = await TestDataGenerator.generateDidKeyPersona(); - - const { recordsWrite, dataStream } = await TestDataGenerator.generateRecordsWrite({ - author : alice, - data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded), + describe('without a grant', () =>{ + describe('records interface messages', () => { + it('returns a 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); + }); + + 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 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, - messageCids : [recordsWriteMessageCid] + describe('Protocol interface messages', () => { + it('returns a 401 if the tenant is not the author', async () => { + // scenario: Alice configures both a published and non-published protocol and writes it to her DWN. + // Bob is unable to get either of the ProtocolConfigure messages because he is not the author. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + // unpublished protocol configuration + const unpublishedProtocolDefinition = { + ...minimalProtocolDefinition, + protocol : 'http://example.com/protocol/unpublished', + published : false + }; + const { message: unpublishedProtocolsConfigure } = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : unpublishedProtocolDefinition + }); + const unpublishedProtocolsConfigureReply = await dwn.processMessage(alice.did, unpublishedProtocolsConfigure); + expect(unpublishedProtocolsConfigureReply.status.code).to.equal(202); + + // published protocol configuration + const publishedProtocolDefinition = { + ...minimalProtocolDefinition, + protocol : 'http://example.com/protocol/published', + published : true + }; + const { message: publishedProtocolsConfigure } = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : publishedProtocolDefinition + }); + const publishedProtocolsConfigureReply = await dwn.processMessage(alice.did, publishedProtocolsConfigure); + expect(publishedProtocolsConfigureReply.status.code).to.equal(202); + + // get the message CIDs + const unpublishedProtocolMessageCid = await Message.getCid(unpublishedProtocolsConfigure); + const publishedProtocolMessageCid = await Message.getCid(publishedProtocolsConfigure); + + // bob attempts to get the unpublished protocol configuration + const { message: getUnpublishedProtocolConfigure } = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : unpublishedProtocolMessageCid, + }); + const getUnpublishedProtocolConfigureReply = await dwn.processMessage(alice.did, getUnpublishedProtocolConfigure); + expect(getUnpublishedProtocolConfigureReply.status.code).to.equal(401); + expect(getUnpublishedProtocolConfigureReply.status.detail).to.include(DwnErrorCode.MessagesGetAuthorizationFailed); + expect(getUnpublishedProtocolConfigureReply.entry).to.be.undefined; + + // bob attempts to get the published protocol configuration + const { message: getPublishedProtocolConfigure } = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : publishedProtocolMessageCid, + }); + const getPublishedProtocolConfigureReply = await dwn.processMessage(alice.did, getPublishedProtocolConfigure); + expect(getPublishedProtocolConfigureReply.status.code).to.equal(401); + expect(getPublishedProtocolConfigureReply.status.detail).to.include(DwnErrorCode.MessagesGetAuthorizationFailed); + expect(getPublishedProtocolConfigureReply.entry).to.be.undefined; + + // control: alice is able to get both the published and unpublished protocol configurations + const { message: getUnpublishedProtocolConfigureAlice } = await TestDataGenerator.generateMessagesGet({ + author : alice, + messageCid : unpublishedProtocolMessageCid, + }); + const getUnpublishedProtocolConfigureAliceReply = await dwn.processMessage(alice.did, getUnpublishedProtocolConfigureAlice); + expect(getUnpublishedProtocolConfigureAliceReply.status.code).to.equal(200); + expect(getUnpublishedProtocolConfigureAliceReply.entry).to.exist; + expect(getUnpublishedProtocolConfigureAliceReply.entry!.messageCid).to.equal(unpublishedProtocolMessageCid); + expect(getUnpublishedProtocolConfigureAliceReply.entry!.message).to.deep.equal(unpublishedProtocolsConfigure); + + const { message: getPublishedProtocolConfigureAlice } = await TestDataGenerator.generateMessagesGet({ + author : alice, + messageCid : publishedProtocolMessageCid, + }); + const getPublishedProtocolConfigureAliceReply = await dwn.processMessage(alice.did, getPublishedProtocolConfigureAlice); + expect(getPublishedProtocolConfigureAliceReply.status.code).to.equal(200); + expect(getPublishedProtocolConfigureAliceReply.entry).to.exist; + expect(getPublishedProtocolConfigureAliceReply.entry!.messageCid).to.equal(publishedProtocolMessageCid); + expect(getPublishedProtocolConfigureAliceReply.entry!.message).to.deep.equal(publishedProtocolsConfigure); + }); }); - - const messagesGetReply: MessagesGetReply = await dwn.processMessage(alice.did, message); - expect(messagesGetReply.status.code).to.equal(200); - expect(messagesGetReply.entries!.length).to.equal(1); - - for (const messageReply of messagesGetReply.entries!) { - expect(messageReply.messageCid).to.exist; - expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); - - expect(messageReply.message).to.exist.and.not.be.undefined; - expect(messageReply.encodedData).to.exist.and.not.be.undefined; - } }); - 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 + describe('with a grant', () => { + it('returns a 401 if grant has different DWN interface scope', async () => { + // scenario: Alice grants Bob access to RecordsWrite, then Bob tries to invoke the grant with MessagesGet + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + // alice installs a protocol + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : minimalProtocolDefinition + }); + 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 : minimalProtocolDefinition.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 scoped to a RecordsWrite and 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.Records, + method : DwnMethodName.Write, + protocol : minimalProtocolDefinition.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 tries to MessagesGet using the RecordsWrite grant + 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 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 : bob, - messageCids : [await Message.getCid(recordsWrite.message)] + 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 alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + // 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); }); - // 0 messages expected because the RecordsWrite created above is not bob's - const messagesGetReply: MessagesGetReply = await dwn.processMessage(bob.did, message); - expect(messagesGetReply.status.code).to.equal(200); - expect(messagesGetReply.entries!.length).to.equal(1); - - for (const messageReply of messagesGetReply.entries!) { - expect(messageReply.messageCid).to.equal(recordsWriteMessageCid); - expect(messageReply.message).to.be.undefined; - } + describe('protocol scoped messages', () => { + it('allows reads of protocol messages with a protocol restricted grant scope', async () => { + // This test will verify that a grant scoped to a specific protocol will allow a user to read messages associated with that protocol. + // These messages include the ProtocolConfiguration itself, even if not published, + // any RecordsWrite or RecordsDelete messages associated with the protocol, + // and any PermissionProtocol RecordsWrite messages associated with the protocol. + + // scenario: Alice configures a protocol that is unpublished and writes it to her DWN. + // Alice then gives Bob a grant to get messages from that protocol. + // Carol requests a grant to RecordsWrite to the protocol, and Alice grants it. + // Alice and Carol write records associated with the protocol. + // Alice also deletes a record associated with the protocol. + // Alice revokes the grant to Carol. + // Bob invokes his grant to read the various messages. + // As a control, Alice writes a record not associated with the protocol and Bob tries to unsuccessfully read it. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + const carol = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = { ...minimalProtocolDefinition, published: false }; + + // Alice installs the unpublished 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); + const protocolConfigureMessageCid = await Message.getCid(protocolsConfig.message); + + // Carol requests a grant to write records to the protocol + const permissionRequestCarol = await PermissionsProtocol.createRequest({ + signer : Jws.createSigner(alice), + delegated : false, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : protocolDefinition.protocol, + } + }); + const requestDataStreamCarol = DataStream.fromBytes(permissionRequestCarol.permissionRequestBytes); + const permissionRequestWriteReplyCarol = await dwn.processMessage( + alice.did, + permissionRequestCarol.recordsWrite.message, + { dataStream: requestDataStreamCarol } + ); + expect(permissionRequestWriteReplyCarol.status.code).to.equal(202); + + // Alice gives Carol a grant to write records to the protocol + const permissionGrantCarol = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + grantedTo : carol.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 * 60 * 24 }), // 24 hours + delegated : permissionRequestCarol.permissionRequestData.delegated, + scope : permissionRequestCarol.permissionRequestData.scope, + }); + + const grantDataStreamCarol = DataStream.fromBytes(permissionGrantCarol.permissionGrantBytes); + const permissionGrantWriteReplyCarol = await dwn.processMessage( + alice.did, + permissionGrantCarol.recordsWrite.message, + { dataStream: grantDataStreamCarol } + ); + expect(permissionGrantWriteReplyCarol.status.code).to.equal(202); + const carolGrantMessageCiD = await Message.getCid(permissionGrantCarol.recordsWrite.message); + + // Alice writes a record associated with the protocol + 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 aliceRecordMessageCid = await Message.getCid(recordsWrite.message); + + // Alice deletes a record associated with the protocol + 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); + + // Carol writes a record associated with the protocol + const { recordsWrite: recordsWriteCarol, dataStream: dataStreamCarol } = await TestDataGenerator.generateRecordsWrite({ + author : carol, + protocol : protocolDefinition.protocol, + protocolPath : 'foo', + permissionGrantId : permissionGrantCarol.recordsWrite.message.recordId, + }); + const recordsWriteReplyCarol = await dwn.processMessage(alice.did, recordsWriteCarol.message, { dataStream: dataStreamCarol }); + expect(recordsWriteReplyCarol.status.code).to.equal(202); + const carolRecordMessageCid = await Message.getCid(recordsWriteCarol.message); + + // Alice revokes Carol's grant + const permissionRevocationCarol = await PermissionsProtocol.createRevocation({ + signer : Jws.createSigner(alice), + grant : await PermissionGrant.parse(permissionGrantCarol.dataEncodedMessage), + }); + const permissionRevocationCarolDataStream = DataStream.fromBytes(permissionRevocationCarol.permissionRevocationBytes); + const permissionRevocationCarolReply = await dwn.processMessage( + alice.did, + permissionRevocationCarol.recordsWrite.message, + { dataStream: permissionRevocationCarolDataStream } + ); + expect(permissionRevocationCarolReply.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 : 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 : aliceRecordMessageCid, + }); + 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 all the associated messages when using the permission grant + // Expected Messages: + // - Protocol Configuration + // - Alice's RecordsWrite + // - Alice's RecordsDelete + // - Carol's Permission Request + // - Alice's Permission Grant to Carol + // - Carol's RecordsWrite + // - Alice's Revocation of Carol's Grant + + // Protocol configuration + const messagesGetProtocolConfigure = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : protocolConfigureMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetProtocolConfigureReply = await dwn.processMessage(alice.did, messagesGetProtocolConfigure.message); + expect(messagesGetProtocolConfigureReply.status.code).to.equal(200); + expect(messagesGetProtocolConfigureReply.entry).to.exist; + expect(messagesGetProtocolConfigureReply.entry!.message).to.deep.equal(protocolsConfig.message); + + // alice RecordsWrite + const messagesGetWithGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : aliceRecordMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetWithGrantReply = await dwn.processMessage(alice.did, messagesGetWithGrant.message); + expect(messagesGetWithGrantReply.status.code).to.equal(200); + expect(messagesGetWithGrantReply.entry).to.exist; + // delete the data field from the message for comparison of the message + delete messagesGetWithGrantReply.entry!.message.data; + expect(messagesGetWithGrantReply.entry!.message).to.deep.equal(recordsWrite.message); + + // alice RecordsDelete + const messagesGetDelete = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : await Message.getCid(recordsDelete.message), + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetDeleteReply = await dwn.processMessage(alice.did, messagesGetDelete.message); + expect(messagesGetDeleteReply.status.code).to.equal(200); + expect(messagesGetDeleteReply.entry).to.exist; + expect(messagesGetDeleteReply.entry!.message).to.deep.equal(recordsDelete.message); + + // carol's Permission Request + const messagesGetCarolRequest = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : await Message.getCid(permissionRequestCarol.recordsWrite.message), + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetCarolRequestReply = await dwn.processMessage(alice.did, messagesGetCarolRequest.message); + expect(messagesGetCarolRequestReply.status.code).to.equal(200); + expect(messagesGetCarolRequestReply.entry).to.exist; + // delete the data field from the message for comparison of the message + delete messagesGetCarolRequestReply.entry!.message.data; + expect(messagesGetCarolRequestReply.entry!.message).to.deep.equal(permissionRequestCarol.recordsWrite.message); + + // carol's Permission Grant + const messagesGetCarolGrant = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : carolGrantMessageCiD, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetCarolGrantReply = await dwn.processMessage(alice.did, messagesGetCarolGrant.message); + expect(messagesGetCarolGrantReply.status.code).to.equal(200); + expect(messagesGetCarolGrantReply.entry).to.exist; + // delete the data field from the message for comparison of the message + delete messagesGetCarolGrantReply.entry!.message.data; + expect(messagesGetCarolGrantReply.entry!.message).to.deep.equal(permissionGrantCarol.recordsWrite.message); + + // carol's RecordsWrite + const messagesGetCarolRecord = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : carolRecordMessageCid, + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetCarolRecordReply = await dwn.processMessage(alice.did, messagesGetCarolRecord.message); + expect(messagesGetCarolRecordReply.status.code).to.equal(200); + expect(messagesGetCarolRecordReply.entry).to.exist; + // delete the data field from the message for comparison of the message + delete messagesGetCarolRecordReply.entry!.message.data; + expect(messagesGetCarolRecordReply.entry!.message).to.deep.equal(recordsWriteCarol.message); + + // carol's Grant Revocation + const messagesGetCarolGrantRevocation = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : await Message.getCid(permissionRevocationCarol.recordsWrite.message), + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetCarolGrantRevocationReply = await dwn.processMessage(alice.did, messagesGetCarolGrantRevocation.message); + expect(messagesGetCarolGrantRevocationReply.status.code).to.equal(200); + expect(messagesGetCarolGrantRevocationReply.entry).to.exist; + // delete the data field from the message for comparison of the message + delete messagesGetCarolGrantRevocationReply.entry!.message.data; + expect(messagesGetCarolGrantRevocationReply.entry!.message).to.deep.equal(permissionRevocationCarol.recordsWrite.message); + + // CONTROL: Alice writes a record not associated with the protocol + const { recordsWrite: recordsWriteControl, dataStream: dataStreamControl } = await TestDataGenerator.generateRecordsWrite({ + author: alice, + }); + const recordsWriteControlReply = await dwn.processMessage(alice.did, recordsWriteControl.message, { dataStream: dataStreamControl }); + expect(recordsWriteControlReply.status.code).to.equal(202); + + // Bob is unable to get the control message + const messagesGetControl = await TestDataGenerator.generateMessagesGet({ + author : bob, + messageCid : await Message.getCid(recordsWriteControl.message), + permissionGrantId : permissionGrant.recordsWrite.message.recordId, + }); + const messagesGetControlReply = await dwn.processMessage(alice.did, messagesGetControl.message); + expect(messagesGetControlReply.status.code).to.equal(401); + }); + + 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); + }); + + 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.RecordsWriteGetNewestWriteRecordNotFound); + }); + }); }); }); } \ No newline at end of file diff --git a/tests/interfaces/messages-get.spec.ts b/tests/interfaces/messages-get.spec.ts index 485544921..ce8b5fd9e 100644 --- a/tests/interfaces/messages-get.spec.ts +++ b/tests/interfaces/messages-get.spec.ts @@ -1,43 +1,28 @@ 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), - messageCids : [messageCid] + signer : await Jws.createSigner(author), + messageCid : messageCid, + messageTimestamp, }); expect(messagesGet.message.authorization).to.exist; expect(messagesGet.message.descriptor).to.exist; - expect(messagesGet.message.descriptor.messageCids.length).to.equal(1); - expect(messagesGet.message.descriptor.messageCids).to.include(messageCid); - }); - - - it('throws an error if at least 1 message cid isnt provided', async () => { - const alice = await TestDataGenerator.generatePersona(); - - try { - await MessagesGet.create({ - signer : await Jws.createSigner(alice), - messageCids : [] - }); - - expect.fail(); - } catch (e: any) { - // error message auto-generated by AJV - expect(e.message).to.include('/descriptor/messageCids: must NOT have fewer than 1 items'); - } + 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 () => { @@ -45,13 +30,13 @@ describe('MessagesGet Message', () => { try { await MessagesGet.create({ - signer : await Jws.createSigner(alice), - messageCids : ['abcd'] + signer : await Jws.createSigner(alice), + messageCid : 'abcd' }); expect.fail(); } catch (e: any) { - expect(e.message).to.include('is not a valid CID'); + expect(e.message).to.include(DwnErrorCode.MessagesGetInvalidCid); } }); }); @@ -62,8 +47,8 @@ describe('MessagesGet Message', () => { let messageCid = await Message.getCid(message); const messagesGet = await MessagesGet.create({ - signer : await Jws.createSigner(author), - messageCids : [messageCid] + signer : await Jws.createSigner(author), + messageCid : messageCid }); const parsed = await MessagesGet.parse(messagesGet.message); @@ -80,12 +65,12 @@ describe('MessagesGet Message', () => { const messageCid = await Message.getCid(recordsWriteMessage); const messagesGet = await MessagesGet.create({ - signer : await Jws.createSigner(author), - messageCids : [messageCid] + signer : await Jws.createSigner(author), + messageCid : messageCid }); const message = messagesGet.toJSON() as MessagesGetMessage; - message.descriptor.messageCids = ['abcd']; + message.descriptor.messageCid = 'abcd'; try { await MessagesGet.parse(message); diff --git a/tests/protocols/permissions.spec.ts b/tests/protocols/permissions.spec.ts new file mode 100644 index 000000000..62edc7d67 --- /dev/null +++ b/tests/protocols/permissions.spec.ts @@ -0,0 +1,170 @@ +import type { MessageStore } from '../../src/index.js'; + +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import chai, { expect } from 'chai'; + +import { Jws } from '../../src/utils/jws.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestStores } from '../test-stores.js'; +import { DwnErrorCode, DwnInterfaceName, DwnMethodName, Encoder, PermissionGrant, PermissionRequest, PermissionsProtocol, Time } from '../../src/index.js'; + +chai.use(chaiAsPromised); + +describe('PermissionsProtocol', () => { + let messageStore: MessageStore; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + + const stores = TestStores.get(); + messageStore = stores.messageStore; + await messageStore.open(); + }); + + + afterEach(async () => { + // restores all fakes, stubs, spies etc. not restoring causes a memory leak. + // more info here: https://sinonjs.org/releases/v13/general-setup/ + sinon.restore(); + await messageStore.clear(); + }); + + after(async () => { + await messageStore.close(); + }); + + describe('getScopeFromPermissionRecord', () => { + it('should get scope from a permission request record', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + // bob creates a request + const permissionRequest = await PermissionsProtocol.createRequest({ + signer : Jws.createSigner(bob), + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Query, + protocol : 'https://example.com/protocol/test' + } + }); + + const request = await PermissionRequest.parse(permissionRequest.dataEncodedMessage); + + const scope = await PermissionsProtocol.getScopeFromPermissionRecord( + alice.did, + messageStore, + permissionRequest.dataEncodedMessage + ); + + expect(scope).to.deep.equal(request.scope); + }); + + it('should get scope from a permission grant record', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const { dataEncodedMessage: grantMessage } = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'https://example.com/protocol/test' + }, + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }) + }); + + const grant = await PermissionGrant.parse(grantMessage); + + const scope = await PermissionsProtocol.getScopeFromPermissionRecord( + alice.did, + messageStore, + grantMessage + ); + + expect(scope).to.deep.equal(grant.scope); + }); + + it('should get scope from a permission revocation record', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const { dataEncodedMessage: grantMessage, recordsWrite: grantRecordsWrite } = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'https://example.com/protocol/test' + }, + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }) + }); + + // store grant in the messageStore so that that the original grant can be retrieved within `getScopeFromPermissionRecord` + const indexes = await grantRecordsWrite.constructIndexes(true); + await messageStore.put(alice.did, grantMessage, indexes); + + const grant = await PermissionGrant.parse(grantMessage); + + const revocation = await PermissionsProtocol.createRevocation({ + signer : Jws.createSigner(alice), + grant : grant + }); + + const scope = await PermissionsProtocol.getScopeFromPermissionRecord( + alice.did, + messageStore, + revocation.dataEncodedMessage + ); + + expect(scope).to.deep.equal(grant.scope); + }); + + it('should throw if there is no grant for the revocation', async () => { + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + + const { dataEncodedMessage: grantMessage } = await PermissionsProtocol.createGrant({ + signer : Jws.createSigner(alice), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'https://example.com/protocol/test' + }, + grantedTo : bob.did, + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }) + }); + + // notice the grant is not stored in the message store + const grant = await PermissionGrant.parse(grantMessage); + + const revocation = await PermissionsProtocol.createRevocation({ + signer : Jws.createSigner(alice), + grant : grant + }); + + await expect(PermissionsProtocol.getScopeFromPermissionRecord( + alice.did, + messageStore, + revocation.dataEncodedMessage + )).to.eventually.be.rejectedWith(DwnErrorCode.GrantAuthorizationGrantMissing); + }); + + it('should throw if the message is not a permission protocol record', async () => { + const recordsWriteMessage = await TestDataGenerator.generateRecordsWrite(); + const dataEncodedMessage = { + ...recordsWriteMessage.message, + encodedData: Encoder.bytesToBase64Url(recordsWriteMessage.dataBytes!) + }; + + await expect(PermissionsProtocol.getScopeFromPermissionRecord( + recordsWriteMessage.author.did, + messageStore, + dataEncodedMessage + )).to.eventually.be.rejectedWith(DwnErrorCode.PermissionsProtocolGetScopeInvalidProtocol); + }); + }); +}); diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 88ef34e4b..091b0d94d 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -242,7 +242,8 @@ export type GenerateEventsSubscribeOutput = { export type GenerateMessagesGetInput = { author?: Persona; - messageCids: string[] + messageCid: string; + permissionGrantId?: string; }; export type GenerateMessagesGetOutput = { @@ -767,7 +768,8 @@ export class TestDataGenerator { const options: MessagesGetOptions = { signer, - messageCids: input.messageCids + messageCid : input.messageCid, + permissionGrantId : input.permissionGrantId }; const messagesGet = await MessagesGet.create(options);