diff --git a/json-schemas/permissions/permissions-definitions.json b/json-schemas/permissions/permissions-definitions.json index f2068ae01..985167cff 100644 --- a/json-schemas/permissions/permissions-definitions.json +++ b/json-schemas/permissions/permissions-definitions.json @@ -25,6 +25,9 @@ }, { "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-write-scope" + }, + { + "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-query-scope" } ] }, @@ -42,4 +45,4 @@ } } } -} +} \ No newline at end of file diff --git a/json-schemas/permissions/scopes.json b/json-schemas/permissions/scopes.json index 1b53c0986..5bd34e0ca 100644 --- a/json-schemas/permissions/scopes.json +++ b/json-schemas/permissions/scopes.json @@ -27,6 +27,9 @@ "method": { "const": "Delete" }, + "protocol": { + "type": "string" + }, "schema": { "type": "string" } @@ -85,6 +88,24 @@ "type": "string" } } + }, + "records-query-scope": { + "type": "object", + "required": [ + "interface", + "method" + ], + "properties": { + "interface": { + "const": "Records" + }, + "method": { + "const": "Query" + }, + "protocol": { + "type": "string" + } + } } } -} +} \ No newline at end of file diff --git a/src/core/abstract-message.ts b/src/core/abstract-message.ts index 0f8be2991..4f038a82b 100644 --- a/src/core/abstract-message.ts +++ b/src/core/abstract-message.ts @@ -13,6 +13,11 @@ export abstract class AbstractMessage implements Messa return this._message as M; } + private _signer: string | undefined; + public get signer(): string | undefined { + return this._signer; + } + private _author: string | undefined; public get author(): string | undefined { return this._author; @@ -27,12 +32,14 @@ export abstract class AbstractMessage implements Messa this._message = message; if (message.authorization !== undefined) { + this._signer = Message.getSigner(message); + // if the message authorization contains author delegated grant, the author would be the grantor of the grant // else the author would be the signer of the message if (message.authorization.authorDelegatedGrant !== undefined) { this._author = Message.getSigner(message.authorization.authorDelegatedGrant); } else { - this._author = Message.getSigner(message as GenericMessage); + this._author = this._signer; } this._signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature); diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 99754cc85..e5aa8b6ec 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -85,6 +85,8 @@ export enum DwnErrorCode { RecordsQueryParseFilterPublishedSortInvalid = 'RecordsQueryParseFilterPublishedSortInvalid', RecordsGrantAuthorizationConditionPublicationProhibited = 'RecordsGrantAuthorizationConditionPublicationProhibited', RecordsGrantAuthorizationConditionPublicationRequired = 'RecordsGrantAuthorizationConditionPublicationRequired', + RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch', + RecordsGrantAuthorizationQueryProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryProtocolScopeMismatch', RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch', RecordsGrantAuthorizationScopeNotProtocol = 'RecordsGrantAuthorizationScopeNotProtocol', RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch', diff --git a/src/core/grant-authorization.ts b/src/core/grant-authorization.ts index 0357f1b28..1ee3d4009 100644 --- a/src/core/grant-authorization.ts +++ b/src/core/grant-authorization.ts @@ -11,20 +11,24 @@ export class GrantAuthorization { /** * Performs PermissionsGrant-based authorization against the given message * Does not validate grant `conditions` or `scope` beyond `interface` and `method` + * @param messageStore Used to check if the grant has been revoked. * @throws {DwnError} if authorization fails */ - public static async authorizeGenericMessage( + public static async authorizeGenericMessage(input: { tenant: string, incomingMessage: GenericMessage, author: string, permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, - ): Promise { + }): Promise { + const { tenant, incomingMessage, author, permissionsGrantMessage, messageStore } = input; const incomingMessageDescriptor = incomingMessage.descriptor; const permissionsGrantId = await Message.getCid(permissionsGrantMessage); - GrantAuthorization.verifyGrantedToAndGrantedFor(author, tenant, permissionsGrantMessage); + const expectedGrantedToInGrant = author; + const expectedGrantedForInGrant = tenant; + GrantAuthorization.verifyExpectedGrantedToAndGrantedFor(expectedGrantedToInGrant, expectedGrantedForInGrant, permissionsGrantMessage); // verify that grant is active during incomingMessage's timestamp await GrantAuthorization.verifyGrantActive( @@ -70,24 +74,31 @@ export class GrantAuthorization { } /** - * Verifies the given `grantedTo` and `grantedFor` values against the given permissions grant and throws error if there is a mismatch. + * Verifies the given `expectedGrantedToInGrant` and `expectedGrantedForInGrant` values against + * the actual `expectedGrantedToInGrant` and `expectedGrantedForInGrant` in given permissions grant. + * @throws {DwnError} if `expectedGrantedToInGrant` or `expectedGrantedForInGrant` do not match the actual values in the grant. */ - private static verifyGrantedToAndGrantedFor(grantedTo: string, grantedFor: string, permissionsGrantMessage: PermissionsGrantMessage): void { - // Validate `grantedTo` - const expectedGrantedTo = permissionsGrantMessage.descriptor.grantedTo; - if (expectedGrantedTo !== grantedTo) { + private static verifyExpectedGrantedToAndGrantedFor( + expectedGrantedToInGrant: string, + expectedGrantedForInGrant: string, + permissionsGrantMessage: PermissionsGrantMessage + ): void { + + // Validate `expectedGrantedToInGrant` + const actualGrantedTo = permissionsGrantMessage.descriptor.grantedTo; + if (expectedGrantedToInGrant !== actualGrantedTo) { throw new DwnError( DwnErrorCode.GrantAuthorizationNotGrantedToAuthor, - `PermissionsGrant has grantedTo ${expectedGrantedTo}, but given ${grantedTo}` + `PermissionsGrant has grantedTo ${actualGrantedTo}, but need to be granted to ${expectedGrantedToInGrant}` ); } - // Validate `grantedFor` - const expectedGrantedFor = permissionsGrantMessage.descriptor.grantedFor; - if (expectedGrantedFor !== grantedFor) { + // Validate `expectedGrantedForInGrant` + const actualGrantedFor = permissionsGrantMessage.descriptor.grantedFor; + if (expectedGrantedForInGrant !== actualGrantedFor) { throw new DwnError( DwnErrorCode.GrantAuthorizationNotGrantedForTenant, - `PermissionsGrant has grantedFor ${expectedGrantedFor}, but given ${grantedFor}` + `PermissionsGrant has grantedFor ${actualGrantedFor}, but need to be granted for ${expectedGrantedForInGrant}` ); } } @@ -96,6 +107,7 @@ export class GrantAuthorization { * Verify that the incoming message is within the allowed time frame of the grant, * and the grant has not been revoked. * @param permissionsGrantId Purely being passed as an optimization. Technically can be computed from `permissionsGrantMessage`. + * @param messageStore Used to check if the grant has been revoked. * @throws {DwnError} if incomingMessage has timestamp for a time in which the grant is not active. */ private static async verifyGrantActive( diff --git a/src/core/message.ts b/src/core/message.ts index c7597f3e0..659e5929a 100644 --- a/src/core/message.ts +++ b/src/core/message.ts @@ -171,6 +171,13 @@ export class Message { return aIsOlder; } + /** + * See if the given message is signed by a delegate + */ + public static isSignedByDelegate(message: GenericMessage): boolean { + return message.authorization?.authorDelegatedGrant !== undefined; + } + /** * Compares the `messageTimestamp` of the given messages with a fallback to message CID according to the spec. * @returns 1 if `a` is larger/newer than `b`; -1 if `a` is smaller/older than `b`; 0 otherwise (same age) diff --git a/src/core/records-grant-authorization.ts b/src/core/records-grant-authorization.ts index 2d7e991bf..61a42db63 100644 --- a/src/core/records-grant-authorization.ts +++ b/src/core/records-grant-authorization.ts @@ -1,8 +1,7 @@ import type { MessageStore } from '../types/message-store.js'; import type { PermissionsGrantMessage } from '../types/permissions-types.js'; import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js'; -import type { RecordsRead } from '../interfaces/records-read.js'; -import type { RecordsWriteMessage } from '../types/records-types.js'; +import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js'; import { GrantAuthorization } from './grant-authorization.js'; import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js'; @@ -19,13 +18,13 @@ export class RecordsGrantAuthorization { permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, ): Promise { - await GrantAuthorization.authorizeGenericMessage( + await GrantAuthorization.authorizeGenericMessage({ tenant, incomingMessage, author, permissionsGrantMessage, messageStore - ); + }); RecordsGrantAuthorization.verifyScope(incomingMessage, permissionsGrantMessage); @@ -34,26 +33,88 @@ export class RecordsGrantAuthorization { /** * Authorizes the scope of a PermissionsGrant for RecordsRead. + * @param messageStore Used to check if the grant has been revoked. */ public static async authorizeRead( tenant: string, - incomingMessage: RecordsRead, + incomingMessage: RecordsReadMessage, newestRecordsWriteMessage: RecordsWriteMessage, author: string, permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, ): Promise { - await GrantAuthorization.authorizeGenericMessage( + await GrantAuthorization.authorizeGenericMessage({ tenant, - incomingMessage.message, + incomingMessage, author, permissionsGrantMessage, messageStore - ); + }); RecordsGrantAuthorization.verifyScope(newestRecordsWriteMessage, permissionsGrantMessage); } + /** + * Authorizes the scope of a PermissionsGrant for RecordsQuery. + * @param messageStore Used to check if the grant has been revoked. + */ + public static async authorizeQuery( + tenant: string, + incomingMessage: RecordsQueryMessage, + author: string, + permissionsGrantMessage: PermissionsGrantMessage, + messageStore: MessageStore, + ): Promise { + await GrantAuthorization.authorizeGenericMessage({ + tenant, + incomingMessage, + author, + permissionsGrantMessage, + messageStore + }); + + // If the grant specifies a protocol, the query must specify the same protocol. + const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol; + const protocolInQuery = incomingMessage.descriptor.filter.protocol; + if (protocolInGrant !== undefined && protocolInQuery !== protocolInGrant) { + throw new DwnError( + DwnErrorCode.RecordsGrantAuthorizationQueryProtocolScopeMismatch, + `Grant protocol scope ${protocolInGrant} does not match protocol in query ${protocolInQuery}` + ); + } + } + + /** + * Authorizes the scope of a PermissionsGrant for RecordsDelete. + * @param messageStore Used to check if the grant has been revoked. + */ + public static async authorizeDelete( + tenant: string, + incomingDeleteMessage: RecordsDeleteMessage, + recordsWriteToDelete: RecordsWriteMessage, + author: string, + permissionsGrantMessage: PermissionsGrantMessage, + messageStore: MessageStore, + ): Promise { + await GrantAuthorization.authorizeGenericMessage({ + tenant, + incomingMessage: incomingDeleteMessage, + author, + permissionsGrantMessage, + messageStore + }); + + // If the grant specifies a protocol, the delete must be deleting a record with the same protocol. + const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol; + const protocolOfRecordToDelete = recordsWriteToDelete.descriptor.protocol; + if (protocolInGrant !== undefined && protocolOfRecordToDelete !== protocolInGrant) { + throw new DwnError( + DwnErrorCode.RecordsGrantAuthorizationDeleteProtocolScopeMismatch, + `Grant protocol scope ${protocolInGrant} does not match protocol in record to delete ${protocolOfRecordToDelete}` + ); + } + } + /** * @param recordsWrite The source of the record being authorized. If the incoming message is a write, * then this is the incoming RecordsWrite. Otherwise, it is the newest existing RecordsWrite. diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index c92dba0fa..db8473fad 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -104,15 +104,20 @@ export class RecordsDeleteHandler implements MethodHandler { }; private static async authorizeRecordsDelete( - tenant: string, recordsDelete: - RecordsDelete, newestRecordsWrite: - RecordsWrite, messageStore: MessageStore + tenant: string, + recordsDelete: RecordsDelete, + recordsWriteToDelete: RecordsWrite, + messageStore: MessageStore ): Promise { + if (Message.isSignedByDelegate(recordsDelete.message)) { + await recordsDelete.authorizeDelegate(recordsWriteToDelete.message, messageStore); + } + if (recordsDelete.author === tenant) { return; - } else if (newestRecordsWrite.message.descriptor.protocol !== undefined) { - await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, newestRecordsWrite, messageStore); + } else if (recordsWriteToDelete.message.descriptor.protocol !== undefined) { + await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, recordsWriteToDelete, messageStore); } else { throw new DwnError( DwnErrorCode.RecordsDeleteAuthorizationFailed, diff --git a/src/handlers/records-query.ts b/src/handlers/records-query.ts index dd4406ff7..c96e77515 100644 --- a/src/handlers/records-query.ts +++ b/src/handlers/records-query.ts @@ -8,6 +8,7 @@ import type { RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry } f import { authenticate } from '../core/auth.js'; import { DateSort } from '../types/records-types.js'; +import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; import { Records } from '../utils/records.js'; @@ -43,10 +44,7 @@ export class RecordsQueryHandler implements MethodHandler { try { await authenticate(message.authorization!, this.didResolver); - // Only run protocol authz if message deliberately invokes it - if (RecordsQueryHandler.shouldProtocolAuthorizeQuery(recordsQuery)) { - await ProtocolAuthorization.authorizeQuery(tenant, recordsQuery, this.messageStore); - } + await RecordsQueryHandler.authorizeRecordsQuery(tenant, recordsQuery, this.messageStore); } catch (e) { return messageReplyFromError(e, 401); } @@ -267,4 +265,23 @@ export class RecordsQueryHandler implements MethodHandler { } return filter.published === false; } + + /** + * @param messageStore Used to check if the grant has been revoked. + */ + private static async authorizeRecordsQuery( + tenant: string, + recordsQuery: RecordsQuery, + messageStore: MessageStore + ): Promise { + + if (Message.isSignedByDelegate(recordsQuery.message)) { + await recordsQuery.authorizeDelegate(messageStore); + } + + // Only run protocol authz if message deliberately invokes it + if (RecordsQueryHandler.shouldProtocolAuthorizeQuery(recordsQuery)) { + await ProtocolAuthorization.authorizeQuery(tenant, recordsQuery, messageStore); + } + } } diff --git a/src/handlers/records-read.ts b/src/handlers/records-read.ts index a1b98e033..46113f87e 100644 --- a/src/handlers/records-read.ts +++ b/src/handlers/records-read.ts @@ -63,21 +63,21 @@ export class RecordsReadHandler implements MethodHandler { ), 400); } - const newestRecordsWrite = existingMessages[0] as RecordsQueryReplyEntry; + const matchedRecordsWrite = existingMessages[0] as RecordsQueryReplyEntry; try { - await RecordsReadHandler.authorizeRecordsRead(tenant, recordsRead, await RecordsWrite.parse(newestRecordsWrite), this.messageStore); + await RecordsReadHandler.authorizeRecordsRead(tenant, recordsRead, await RecordsWrite.parse(matchedRecordsWrite), this.messageStore); } catch (error) { return messageReplyFromError(error, 401); } let data; - if (newestRecordsWrite.encodedData !== undefined) { - const dataBytes = Encoder.base64UrlToBytes(newestRecordsWrite.encodedData); + if (matchedRecordsWrite.encodedData !== undefined) { + const dataBytes = Encoder.base64UrlToBytes(matchedRecordsWrite.encodedData); data = DataStream.fromBytes(dataBytes); - delete newestRecordsWrite.encodedData; + delete matchedRecordsWrite.encodedData; } else { - const messageCid = await Message.getCid(newestRecordsWrite); - const result = await this.dataStore.get(tenant, messageCid, newestRecordsWrite.descriptor.dataCid); + const messageCid = await Message.getCid(matchedRecordsWrite); + const result = await this.dataStore.get(tenant, messageCid, matchedRecordsWrite.descriptor.dataCid); if (result?.dataStream === undefined) { return { status: { code: 404, detail: 'Not Found' } @@ -87,7 +87,7 @@ export class RecordsReadHandler implements MethodHandler { } const record = { - ...newestRecordsWrite, + ...matchedRecordsWrite, data }; @@ -109,13 +109,20 @@ export class RecordsReadHandler implements MethodHandler { return messageReply; }; + /** + * @param messageStore Used to check if the grant has been revoked. + */ private static async authorizeRecordsRead( tenant: string, recordsRead: RecordsRead, - newestRecordsWrite: RecordsWrite, + matchedRecordsWrite: RecordsWrite, messageStore: MessageStore ): Promise { - const { descriptor } = newestRecordsWrite.message; + if (Message.isSignedByDelegate(recordsRead.message)) { + await recordsRead.authorizeDelegate(matchedRecordsWrite.message, messageStore); + } + + const { descriptor } = matchedRecordsWrite.message; // if author is the same as the target tenant, we can directly grant access if (recordsRead.author === tenant) { @@ -129,10 +136,10 @@ export class RecordsReadHandler implements MethodHandler { } else if (recordsRead.author !== undefined && recordsRead.signaturePayload!.permissionsGrantId !== undefined) { const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsRead.signaturePayload!.permissionsGrantId); await RecordsGrantAuthorization.authorizeRead( - tenant, recordsRead, newestRecordsWrite.message, recordsRead.author, permissionsGrantMessage, messageStore + tenant, recordsRead.message, matchedRecordsWrite.message, recordsRead.author, permissionsGrantMessage, messageStore ); } else if (descriptor.protocol !== undefined) { - await ProtocolAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, messageStore); + await ProtocolAuthorization.authorizeRead(tenant, recordsRead, matchedRecordsWrite, messageStore); } else { throw new DwnError(DwnErrorCode.RecordsReadAuthorizationFailed, 'message failed authorization'); } diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 33eb15b97..8d66b1f5c 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -290,8 +290,8 @@ export class RecordsWriteHandler implements MethodHandler { ); } - if (recordsWrite.isSignedByDelegatee) { - await recordsWrite.authorizeDelegatee(messageStore); + if (recordsWrite.isSignedByDelegate) { + await recordsWrite.authorizeDelegate(messageStore); } if (recordsWrite.owner !== undefined) { diff --git a/src/interfaces/protocols-query.ts b/src/interfaces/protocols-query.ts index 1aaa758bf..12d35ea84 100644 --- a/src/interfaces/protocols-query.ts +++ b/src/interfaces/protocols-query.ts @@ -82,13 +82,13 @@ export class ProtocolsQuery extends AbstractMessage { return; } else if (this.author !== undefined && this.signaturePayload!.permissionsGrantId) { const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, this.signaturePayload!.permissionsGrantId); - await GrantAuthorization.authorizeGenericMessage( + await GrantAuthorization.authorizeGenericMessage({ tenant, - this.message, - this.author, + incomingMessage : this.message, + author : this.author, permissionsGrantMessage, messageStore - ); + }); } else { throw new DwnError( DwnErrorCode.ProtocolsQueryUnauthorized, diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index cbc29013f..3cf52a6cf 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -1,10 +1,12 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { MessageStore } from '../types//message-store.js'; import type { Signer } from '../types/signer.js'; -import type { RecordsDeleteDescriptor, RecordsDeleteMessage } from '../types/records-types.js'; +import type { RecordsDeleteDescriptor, RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; import { Records } from '../utils/records.js'; +import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -64,4 +66,15 @@ export class RecordsDelete extends AbstractMessage { return new RecordsDelete(message); } + + /** + * Authorizes the delegate who signed the message. + * @param messageStore Used to check if the grant has been revoked. + */ + public async authorizeDelegate(recordsWriteToDelete: RecordsWriteMessage, messageStore: MessageStore): Promise { + const grantedTo = this.signer!; + const grantedFor = this.author!; + const delegatedGrant = this.message.authorization!.authorDelegatedGrant!; + await RecordsGrantAuthorization.authorizeDelete(grantedFor, this.message, recordsWriteToDelete, grantedTo, delegatedGrant, messageStore); + } } diff --git a/src/interfaces/records-query.ts b/src/interfaces/records-query.ts index 776d5cc4a..02e88f120 100644 --- a/src/interfaces/records-query.ts +++ b/src/interfaces/records-query.ts @@ -1,4 +1,5 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { MessageStore } from '../types//message-store.js'; import type { Pagination } from '../types/message-types.js'; import type { Signer } from '../types/signer.js'; import type { RecordsFilter, RecordsQueryDescriptor, RecordsQueryMessage } from '../types/records-types.js'; @@ -7,6 +8,7 @@ import { AbstractMessage } from '../core/abstract-message.js'; import { DateSort } from '../types/records-types.js'; import { Message } from '../core/message.js'; import { Records } from '../utils/records.js'; +import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; @@ -111,4 +113,15 @@ export class RecordsQuery extends AbstractMessage { return new RecordsQuery(message); } + + /** + * Authorizes the delegate who signed the message. + * @param messageStore Used to check if the grant has been revoked. + */ + public async authorizeDelegate(messageStore: MessageStore): Promise { + const grantedTo = this.signer!; + const grantedFor = this.author!; + const delegatedGrant = this.message.authorization!.authorDelegatedGrant!; + await RecordsGrantAuthorization.authorizeQuery(grantedFor, this.message, grantedTo, delegatedGrant, messageStore); + } } diff --git a/src/interfaces/records-read.ts b/src/interfaces/records-read.ts index 60de58c87..3744736f5 100644 --- a/src/interfaces/records-read.ts +++ b/src/interfaces/records-read.ts @@ -1,10 +1,12 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { MessageStore } from '../types//message-store.js'; import type { Signer } from '../types/signer.js'; -import type { RecordsFilter , RecordsReadDescriptor, RecordsReadMessage } from '../types/records-types.js'; +import type { RecordsFilter , RecordsReadDescriptor, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; import { Records } from '../utils/records.js'; +import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -79,4 +81,15 @@ export class RecordsRead extends AbstractMessage { return new RecordsRead(message); } + + /** + * Authorizes the delegate who signed the message. + * @param messageStore Used to check if the grant has been revoked. + */ + public async authorizeDelegate(matchedRecordsWrite: RecordsWriteMessage, messageStore: MessageStore): Promise { + const grantedTo = this.signer!; + const grantedFor = this.author!; + const delegatedGrant = this.message.authorization!.authorDelegatedGrant!; + await RecordsGrantAuthorization.authorizeRead(grantedFor, this.message, matchedRecordsWrite, grantedTo, delegatedGrant, messageStore); + } } diff --git a/src/interfaces/records-write.ts b/src/interfaces/records-write.ts index 42ba0b5ec..3931b2b5a 100644 --- a/src/interfaces/records-write.ts +++ b/src/interfaces/records-write.ts @@ -185,10 +185,10 @@ export class RecordsWrite implements MessageInterface { } /** - * If this message is signed by a delegated entity. + * If this message is signed by a delegate. */ - public get isSignedByDelegatee(): boolean { - return this._message.authorization?.authorDelegatedGrant !== undefined; + public get isSignedByDelegate(): boolean { + return Message.isSignedByDelegate(this._message); } /** @@ -713,14 +713,13 @@ export class RecordsWrite implements MessageInterface { return indexes; } - public async authorizeDelegatee(messageStore: MessageStore): Promise { + public async authorizeDelegate(messageStore: MessageStore): Promise { const grantedTo = this.signer!; const grantedFor = this.author!; const delegatedGrant = this.message.authorization.authorDelegatedGrant!; await RecordsGrantAuthorization.authorizeWrite(grantedFor, this.message, grantedTo, delegatedGrant, messageStore); } - /** * Checks if the given message is the initial entry of a record. */ diff --git a/src/types/message-interface.ts b/src/types/message-interface.ts index 6f2820ff4..25c5f4008 100644 --- a/src/types/message-interface.ts +++ b/src/types/message-interface.ts @@ -9,6 +9,12 @@ export interface MessageInterface { */ get message(): M; + /** + * Gets the signer of this message. + * This is not to be confused with the logical author of the message. + */ + get signer(): string | undefined; + /** * DID of the logical author of this message. * NOTE: we say "logical" author because a message can be signed by a delegate of the actual author, diff --git a/src/types/permissions-grant-descriptor.ts b/src/types/permissions-grant-descriptor.ts index 76714fd58..4218f3f5e 100644 --- a/src/types/permissions-grant-descriptor.ts +++ b/src/types/permissions-grant-descriptor.ts @@ -53,7 +53,7 @@ export type PermissionScope = { // Method-specific scopes export type RecordsPermissionScope = { interface: DwnInterfaceName.Records; - method: DwnMethodName.Read | DwnMethodName.Write; + method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Query | DwnMethodName.Delete; /** May only be present when `schema` is undefined */ protocol?: string; /** May only be present when `protocol` is defined and `protocolPath` is undefined */ diff --git a/tests/interfaces/records-write.spec.ts b/tests/interfaces/records-write.spec.ts index 518dd1674..18de8cc87 100644 --- a/tests/interfaces/records-write.spec.ts +++ b/tests/interfaces/records-write.spec.ts @@ -357,7 +357,7 @@ describe('RecordsWrite', () => { }); }); - describe('isSignedByDelegatee()', () => { + describe('isSignedByDelegate()', () => { it('should return false if the given RecordsWrite is not signed at all', async () => { const data = new TextEncoder().encode('any data'); const recordsWrite = await RecordsWrite.create({ @@ -368,8 +368,8 @@ describe('RecordsWrite', () => { data }); - const isSignedByDelegatee = recordsWrite.isSignedByDelegatee; - expect(isSignedByDelegatee).to.be.false; + const isSignedByDelegate = recordsWrite.isSignedByDelegate; + expect(isSignedByDelegate).to.be.false; }); }); diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index cfc1a4d16..eb975c995 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -89,7 +89,6 @@ export function testDelegatedGrantScenarios(): void { const deviceXGrant = await PermissionsGrant.create({ delegated : true, // this is a delegated grant dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), - description : 'Allow to write to message protocol', grantedBy : alice.did, grantedTo : deviceX.did, grantedFor : alice.did, @@ -100,7 +99,6 @@ export function testDelegatedGrantScenarios(): void { const deviceYGrant = await PermissionsGrant.create({ delegated : true, // this is a delegated grant dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), - description : 'Allow to write to message protocol', grantedBy : alice.did, grantedTo : deviceY.did, grantedFor : alice.did, @@ -182,7 +180,7 @@ export function testDelegatedGrantScenarios(): void { it('should only allow correct entity invoking a delegated grant to read or query', async () => { // scenario: - // 1. Alice creates a delegated grant for device X, + // 1. Alice creates read and query delegated grants for device X, // 2. Bob starts a chat thread with Alice on his DWN // 3. device X should be able to read the chat thread // 4. Carol should not be able to read the chat thread using device X's delegated grant @@ -234,22 +232,34 @@ export function testDelegatedGrantScenarios(): void { const chatRecordReply = await dwn.processMessage(bob.did, chatRecord.message, chatRecord.dataStream); expect(chatRecordReply.status.code).to.equal(202); - // Alice creates a delegated grant for device X to act as Alice. - const scope: PermissionScope = { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Write, - protocol - }; + // Alice creates a delegated query grant for device X to act as Alice. + const queryGrantForDeviceX = await PermissionsGrant.create({ + delegated : true, // this is a delegated grant + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), + grantedBy : alice.did, + grantedTo : deviceX.did, + grantedFor : alice.did, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Query, + protocol + }, + signer: Jws.createSigner(alice) + }); - const grantToDeviceX = await PermissionsGrant.create({ + // Alice creates a delegated read grant for device X to act as Alice. + const readGrantForDeviceX = await PermissionsGrant.create({ delegated : true, // this is a delegated grant dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), - description : 'Allow device X to write as me in chat protocol', grantedBy : alice.did, grantedTo : deviceX.did, grantedFor : alice.did, - scope : scope, - signer : Jws.createSigner(alice) + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Read, + protocol + }, + signer: Jws.createSigner(alice) }); // sanity verify Bob himself is able to query for the chat thread from Bob's DWN @@ -278,7 +288,7 @@ export function testDelegatedGrantScenarios(): void { // verify device X is able to query for the chat message from Bob's DWN const recordsQueryByDeviceX = await RecordsQuery.create({ signer : Jws.createSigner(deviceX), - delegatedGrant : grantToDeviceX.asDelegatedGrant(), + delegatedGrant : queryGrantForDeviceX.asDelegatedGrant(), protocolRole : 'thread/participant', filter : { protocol, @@ -293,7 +303,7 @@ export function testDelegatedGrantScenarios(): void { // verify device X is able to read the chat message from Bob's DWN const recordsReadByDeviceX = await RecordsRead.create({ signer : Jws.createSigner(deviceX), - delegatedGrant : grantToDeviceX.asDelegatedGrant(), + delegatedGrant : readGrantForDeviceX.asDelegatedGrant(), protocolRole : 'thread/participant', filter : { recordId: chatRecord.message.recordId @@ -306,7 +316,7 @@ export function testDelegatedGrantScenarios(): void { // Verify that Carol cannot query as Alice by invoking the delegated grant granted to Device X const recordsQueryByCarol = await RecordsQuery.create({ signer : Jws.createSigner(carol), - delegatedGrant : grantToDeviceX.asDelegatedGrant(), + delegatedGrant : readGrantForDeviceX.asDelegatedGrant(), protocolRole : 'thread/participant', filter : { protocol, @@ -321,7 +331,7 @@ export function testDelegatedGrantScenarios(): void { // Verify that Carol cannot read as Alice by invoking the delegated grant granted to Device X const recordsReadByCarol = await RecordsRead.create({ signer : Jws.createSigner(carol), - delegatedGrant : grantToDeviceX.asDelegatedGrant(), + delegatedGrant : readGrantForDeviceX.asDelegatedGrant(), protocolRole : 'thread/participant', filter : { recordId: chatRecord.message.recordId @@ -332,7 +342,7 @@ export function testDelegatedGrantScenarios(): void { expect(recordsQueryByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); }); - it('should only allow entity invoking a delegated grant to delete', async () => { + it('should only allow correct entity invoking a delegated grant to delete', async () => { // scenario: // 1. Bob installs the chat protocol on his DWN and makes Alice an admin // 2. Bob starts a chat thread with Carol on his DWN @@ -399,28 +409,25 @@ export function testDelegatedGrantScenarios(): void { const chatRecordReply = await dwn.processMessage(bob.did, chatRecord.message, chatRecord.dataStream); expect(chatRecordReply.status.code).to.equal(202); - // Alice creates a delegated grant for device X to act as Alice. - const scope: PermissionScope = { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Write, - protocol - }; - - const grantToDeviceX = await PermissionsGrant.create({ + // Alice creates a delegated delete grant for device X to act as Alice. + const deleteGrantForDeviceX = await PermissionsGrant.create({ delegated : true, // this is a delegated grant dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), - description : 'Allow device X to write as me in chat protocol', grantedBy : alice.did, grantedTo : deviceX.did, grantedFor : alice.did, - scope : scope, - signer : Jws.createSigner(alice) + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Delete, + protocol + }, + signer: Jws.createSigner(alice) }); // verify Carol is not able to delete Carol's chat message from Bob's DWN const recordsDeleteByCarol = await RecordsDelete.create({ signer : Jws.createSigner(carol), - delegatedGrant : grantToDeviceX.asDelegatedGrant(), + delegatedGrant : deleteGrantForDeviceX.asDelegatedGrant(), protocolRole : 'thread/participant', recordId : chatRecord.message.recordId }); @@ -439,7 +446,7 @@ export function testDelegatedGrantScenarios(): void { // verify device X is able to delete Carol's chat message from Bob's DWN const recordsDeleteByDeviceX = await RecordsDelete.create({ signer : Jws.createSigner(deviceX), - delegatedGrant : grantToDeviceX.asDelegatedGrant(), + delegatedGrant : deleteGrantForDeviceX.asDelegatedGrant(), protocolRole : 'globalAdmin', recordId : chatRecord.message.recordId }); @@ -484,7 +491,6 @@ export function testDelegatedGrantScenarios(): void { const deviceXGrant = await PermissionsGrant.create({ delegated : true, // this is a delegated grant dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), - description : 'Allow to write to some random protocol', grantedBy : alice.did, grantedTo : deviceX.did, grantedFor : alice.did, @@ -520,13 +526,219 @@ export function testDelegatedGrantScenarios(): void { expect(deviceXWriteReply.status.detail).to.contain(DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch); }); - xit('should evaluate scoping correctly when invoking a delegated grant to read', async () => { - }); + it('should fail if delegated grant has a mismatching protocol scope - query & read', async () => { + // scenario: + // 1. Alice creates a delegated grant for device X to act as her for a protocol that is NOT chat protocol + // 2. Bob starts a chat thread with Alice on his DWN + // 3. Device X attempts to use the delegated grant to read the chat thread + // 4. Bob's DWN should reject Device X's read attempt + + const alice = await DidKeyResolver.generate(); + const deviceX = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // Bob has the chat protocol installed + const protocolDefinition = threadRoleProtocolDefinition; + const protocol = threadRoleProtocolDefinition.protocol; + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: bob, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(bob.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Bob starts a chat thread + const threadRecord = await TestDataGenerator.generateRecordsWrite({ + author : bob, + protocol : protocolDefinition.protocol, + protocolPath : 'thread', + }); + const threadRoleReply = await dwn.processMessage(bob.did, threadRecord.message, threadRecord.dataStream); + expect(threadRoleReply.status.code).to.equal(202); + + // Bob adds Alice as a participant in the thread + const participantRoleRecord = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/participant', + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Alice is my friend'), + }); + const participantRoleReply = await dwn.processMessage(bob.did, participantRoleRecord.message, participantRoleRecord.dataStream); + expect(participantRoleReply.status.code).to.equal(202); + + // Bob writes a chat message in the thread + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : bob, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + }); + const chatRecordReply = await dwn.processMessage(bob.did, chatRecord.message, chatRecord.dataStream); + expect(chatRecordReply.status.code).to.equal(202); + + // Alice creates a delegated query grant for device X to act as Alice but not for chat protocol + const queryGrantForDeviceX = await PermissionsGrant.create({ + delegated : true, // this is a delegated grant + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), + grantedBy : alice.did, + grantedTo : deviceX.did, + grantedFor : alice.did, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Query, + protocol : 'some-protocol' + }, + signer: Jws.createSigner(alice) + }); + + // Alice creates a delegated read grant for device X to act as Alice but not for chat protocol + const readGrantForDeviceX = await PermissionsGrant.create({ + delegated : true, // this is a delegated grant + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), + grantedBy : alice.did, + grantedTo : deviceX.did, + grantedFor : alice.did, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Read, + protocol : 'some-protocol' + }, + signer: Jws.createSigner(alice) + }); + + // verify device X querying for the chat message from Bob's DWN fails + const recordsQueryByDeviceX = await RecordsQuery.create({ + signer : Jws.createSigner(deviceX), + delegatedGrant : queryGrantForDeviceX.asDelegatedGrant(), + protocolRole : 'thread/participant', + filter : { + protocol, + contextId : threadRecord.message.contextId, + protocolPath : 'thread/chat' + } + }); + const deviceXRecordsQueryReply = await dwn.processMessage(bob.did, recordsQueryByDeviceX.message); + expect(deviceXRecordsQueryReply.status.code).to.equal(401); + expect(deviceXRecordsQueryReply.status.detail).to.contain(DwnErrorCode.RecordsGrantAuthorizationQueryProtocolScopeMismatch); + + // verify device X reading for the chat message from Bob's DWN fails + const recordsReadByDeviceX = await RecordsRead.create({ + signer : Jws.createSigner(deviceX), + delegatedGrant : readGrantForDeviceX.asDelegatedGrant(), + protocolRole : 'thread/participant', + filter : { + recordId: chatRecord.message.recordId + } + }); - xit('should evaluate scoping correctly when invoking a delegated grant to query', async () => { + const deviceXWriteReply = await dwn.processMessage(bob.did, recordsReadByDeviceX.message); + expect(deviceXWriteReply.status.code).to.equal(401); + expect(deviceXWriteReply.status.detail).to.contain(DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch); }); - xit('should evaluate scoping correctly when invoking a delegated grant to delete', async () => { + it('should fail if delegated grant has a mismatching protocol scope - delete', async () => { + // scenario: + // 1. Bob installs the chat protocol on his DWN and makes Alice an admin + // 2. Bob starts a chat thread with Carol on his DWN + // 3. Alice creates a delegated delete grant for Device X to act as her for a protocol that is NOT chat protocol + // 4. Device X should NOT be able to delete a chat message as Alice + const alice = await DidKeyResolver.generate(); + const deviceX = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + const carol = await DidKeyResolver.generate(); + + // Bob has the chat protocol installed + const protocolDefinition = threadRoleProtocolDefinition; + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: bob, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(bob.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Bob adds Alice as an admin + const globalAdminRecord = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'globalAdmin', + data : new TextEncoder().encode('I trust Alice to manage my chat thread'), + }); + const globalAdminRecordReply = await dwn.processMessage(bob.did, globalAdminRecord.message, globalAdminRecord.dataStream); + expect(globalAdminRecordReply.status.code).to.equal(202); + + // Bob starts a chat thread + const threadRecord = await TestDataGenerator.generateRecordsWrite({ + author : bob, + protocol : protocolDefinition.protocol, + protocolPath : 'thread', + }); + const threadRoleReply = await dwn.processMessage(bob.did, threadRecord.message, threadRecord.dataStream); + expect(threadRoleReply.status.code).to.equal(202); + + // Bob adds Carol as a participant in the thread + const participantRoleRecord = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : carol.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/participant', + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId + }); + const participantRoleReply = await dwn.processMessage(bob.did, participantRoleRecord.message, participantRoleRecord.dataStream); + expect(participantRoleReply.status.code).to.equal(202); + + // Carol writes a chat message in the thread + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : carol, + protocolRole : 'thread/participant', + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('A rude message'), + }); + const chatRecordReply = await dwn.processMessage(bob.did, chatRecord.message, chatRecord.dataStream); + expect(chatRecordReply.status.code).to.equal(202); + + // Alice creates a delegated delete grant for Device X to act as her for a protocol that is NOT chat protocol + const delegatedGrantForDeviceX = await PermissionsGrant.create({ + delegated : true, // this is a delegated grant + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), + grantedBy : alice.did, + grantedTo : deviceX.did, + grantedFor : alice.did, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Delete, + protocol : 'some-protocol-that-is-not-chat' + }, + signer: Jws.createSigner(alice) + }); + + // verify device X is NOT able to delete Carol's chat message from Bob's DWN + const recordsDeleteByDeviceX = await RecordsDelete.create({ + signer : Jws.createSigner(deviceX), + delegatedGrant : delegatedGrantForDeviceX.asDelegatedGrant(), + protocolRole : 'globalAdmin', + recordId : chatRecord.message.recordId + }); + const deviceXRecordsDeleteReply = await dwn.processMessage(bob.did, recordsDeleteByDeviceX.message); + expect(deviceXRecordsDeleteReply.status.code).to.equal(401); + expect(deviceXRecordsDeleteReply.status.detail).to.contain(DwnErrorCode.RecordsGrantAuthorizationDeleteProtocolScopeMismatch); + + // sanity verify the chat message is still in Bob's DWN + const recordsQueryByBob = await TestDataGenerator.generateRecordsQuery({ + author : bob, + filter : { protocolPath: 'thread/chat' } + }); + const bobRecordsQueryReply = await dwn.processMessage(bob.did, recordsQueryByBob.message); + expect(bobRecordsQueryReply.status.code).to.equal(200); + expect(bobRecordsQueryReply.entries?.length).to.equal(1); }); xit('should not be able to create a RecordsWrite with a non-delegated grant assigned to `authorDelegatedGrant`', async () => {