Skip to content

Commit

Permalink
Permissions Grants for MessagesGet (#748)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
LiranCohen authored Jun 26, 2024
1 parent d96caec commit 592597a
Show file tree
Hide file tree
Showing 15 changed files with 1,149 additions and 265 deletions.
8 changes: 2 additions & 6 deletions json-schemas/interface-methods/messages-get.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions json-schemas/permissions/permissions-definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
19 changes: 19 additions & 0 deletions json-schemas/permissions/scopes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 5 additions & 11 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -77,7 +76,6 @@ export enum DwnErrorCode {
ProtocolAuthorizationNotARole = 'ProtocolAuthorizationNotARole',
ProtocolAuthorizationParentNotFoundConstructingRecordChain = 'ProtocolAuthorizationParentNotFoundConstructingRecordChain',
ProtocolAuthorizationProtocolNotFound = 'ProtocolAuthorizationProtocolNotFound',
ProtocolAuthorizationQueryWithoutRole = 'ProtocolAuthorizationQueryWithoutRole',
ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient',
ProtocolAuthorizationTagsInvalidSchema = 'ProtocolAuthorizationTagsInvalidSchema',
ProtocolsConfigureDuplicateActorInRuleSet = 'ProtocolsConfigureDuplicateActorInRuleSet',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -137,6 +131,7 @@ export enum DwnErrorCode {
RecordsWriteDataCidMismatch = 'RecordsWriteDataCidMismatch',
RecordsWriteDataSizeMismatch = 'RecordsWriteDataSizeMismatch',
RecordsWriteGetEntryIdUndefinedAuthor = 'RecordsWriteGetEntryIdUndefinedAuthor',
RecordsWriteGetNewestWriteRecordNotFound = 'RecordsWriteGetNewestWriteRecordNotFound',
RecordsWriteGetInitialWriteNotFound = 'RecordsWriteGetInitialWriteNotFound',
RecordsWriteImmutablePropertyChanged = 'RecordsWriteImmutablePropertyChanged',
RecordsWriteMissingSigner = 'RecordsWriteMissingSigner',
Expand Down Expand Up @@ -164,5 +159,4 @@ export enum DwnErrorCode {
UrlProtocolNotNormalized = 'UrlProtocolNotNormalized',
UrlProtocolNotNormalizable = 'UrlProtocolNotNormalizable',
UrlSchemaNotNormalized = 'UrlSchemaNotNormalized',
UrlSchemaNotNormalizable = 'UrlSchemaNotNormalizable',
};
97 changes: 97 additions & 0 deletions src/core/messages-grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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');
}
}
95 changes: 59 additions & 36 deletions src/handlers/messages-get.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand All @@ -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<MessagesGetReplyEntry>[] = [];
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<void> {

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');
}
}
}
Loading

0 comments on commit 592597a

Please sign in to comment.