Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Permissions Grants for MessagesGet #748

Merged
merged 6 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
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