Skip to content

Commit

Permalink
refactor for review comments
Browse files Browse the repository at this point in the history
  • Loading branch information
LiranCohen committed Jun 26, 2024
1 parent ccf5b55 commit b82212b
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 53 deletions.
2 changes: 1 addition & 1 deletion src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export enum DwnErrorCode {
JwsDecodePlainObjectPayloadInvalid = 'JwsDecodePlainObjectPayloadInvalid',
MessagesGetInvalidCid = 'MessagesGetInvalidCid',
MessagesGetAuthorizationFailed = 'MessagesGetAuthorizationFailed',
MessagesGetWriteRecordNotFound = 'MessagesGetWriteRecordNotFound',
MessagesGetVerifyScopeFailed = 'MessagesGetVerifyScopeFailed',
ParseCidCodecNotSupported = 'ParseCidCodecNotSupported',
ParseCidMultihashNotSupported = 'ParseCidMultihashNotSupported',
Expand Down Expand Up @@ -132,6 +131,7 @@ export enum DwnErrorCode {
RecordsWriteDataCidMismatch = 'RecordsWriteDataCidMismatch',
RecordsWriteDataSizeMismatch = 'RecordsWriteDataSizeMismatch',
RecordsWriteGetEntryIdUndefinedAuthor = 'RecordsWriteGetEntryIdUndefinedAuthor',
RecordsWriteGetNewestWriteRecordNotFound = 'RecordsWriteGetNewestWriteRecordNotFound',
RecordsWriteGetInitialWriteNotFound = 'RecordsWriteGetInitialWriteNotFound',
RecordsWriteImmutablePropertyChanged = 'RecordsWriteImmutablePropertyChanged',
RecordsWriteMissingSigner = 'RecordsWriteMissingSigner',
Expand Down
57 changes: 11 additions & 46 deletions src/core/messages-grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
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 type { MessagesPermissionScope, PermissionScope } from '../types/permission-types.js';

import { DwnInterfaceName } from '../enums/dwn-interface-method.js';
import { GrantAuthorization } from './grant-authorization.js';
import { Message } from './message.js';
import { PermissionGrant } from '../protocols/permission-grant.js';
import { PermissionRequest } from '../protocols/permission-request.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';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';

export class MessagesGrantAuthorization {

/**
* Authorizes a MessagesGetMessage using the given permission grant.
* @param messageStore Used to check if the given grant has been revoked.
* @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,
Expand Down Expand Up @@ -62,7 +61,7 @@ export class MessagesGrantAuthorization {
// 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 MessagesGrantAuthorization.getRecordsWriteMessageToAuthorize(tenant, recordsMessage, messageStore);
await RecordsWrite.fetchNewestRecordsWrite(messageStore, tenant, recordsMessage.descriptor.recordId);

if (recordsWriteMessage.descriptor.protocol === incomingScope.protocol) {
// the record protocol matches the incoming scope protocol
Expand All @@ -72,19 +71,11 @@ export class MessagesGrantAuthorization {
// 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
// if the permission message is a revocation, the scope is fetched from the grant that is being revoked
let permissionScope!: PermissionScope;
if (recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.revocationPath) {
const grant = await PermissionsProtocol.fetchGrant(tenant, messageStore, recordsWriteMessage.descriptor.parentId!);
permissionScope = grant.scope;
return;
} else if (recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.grantPath) {
const grant = await PermissionGrant.parse(recordsWriteMessage as DataEncodedRecordsWriteMessage);
permissionScope = grant.scope;
} else if (recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.requestPath) {
const request = await PermissionRequest.parse(recordsWriteMessage as DataEncodedRecordsWriteMessage);
permissionScope = request.scope;
}
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
Expand All @@ -103,30 +94,4 @@ export class MessagesGrantAuthorization {

throw new DwnError(DwnErrorCode.MessagesGetVerifyScopeFailed, 'record message failed scope authorization');
}

/**
* Get the RecordsWriteMessage associated with the given RecordsDeleteMessage in order to authorize access.
*/
private static async getRecordsWriteMessageToAuthorize(
tenant: string,
message: RecordsDeleteMessage,
messageStore: MessageStore
): Promise<RecordsWriteMessage> {
// get existing RecordsWrite messages matching the `recordId`
const query = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
recordId : message.descriptor.recordId
};

const { messages: existingMessages } = await messageStore.query(tenant, [ query ]);
const newestWrite = await Message.getNewestMessage(existingMessages);
if (newestWrite !== undefined) {
return newestWrite as RecordsWriteMessage;
}

// It shouldn't be possible to get here, as the `RecordsDeleteMessage` should always have a corresponding `RecordsWriteMessage`.
// But we add this in for defensive programming
throw new DwnError(DwnErrorCode.MessagesGetWriteRecordNotFound, 'record not found');
}
}
6 changes: 1 addition & 5 deletions src/handlers/messages-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,6 @@ export class MessagesGetHandler implements MethodHandler {
const result = await this.dataStore.get(tenant, recordsWrite.recordId, recordsWrite.descriptor.dataCid);
if (result?.dataStream !== undefined) {
entry.message.data = result.dataStream;
} else {
// if there is no data, return with the data property undefined
// when records are deleted, their data is removed from the data store but the message remains in the message store
delete entry.message.data;
}
}
}
Expand All @@ -77,7 +73,7 @@ export class MessagesGetHandler implements MethodHandler {
}

/**
* @param messageStore Used to check if the grant has been revoked.
* @param messageStore Used to fetch related permission grant, permission revocation, and/or RecordsWrites for permission scope validation.
*/
private static async authorizeMessagesGet(
tenant: string,
Expand Down
20 changes: 20 additions & 0 deletions src/interfaces/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,26 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
return attesters;
}

public static async fetchNewestRecordsWrite(
messageStore: MessageStore,
tenant: string,
recordId: string,
): Promise<RecordsWriteMessage> {
// 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.
Expand Down
32 changes: 32 additions & 0 deletions src/protocols/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -398,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<PermissionScope> {
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.
Expand Down
2 changes: 1 addition & 1 deletion tests/handlers/messages-get.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ export function testMessagesGetHandler(): void {
});
const messagesGetReply = await dwn.processMessage(alice.did, messagesGet.message);
expect(messagesGetReply.status.code).to.equal(401);
expect(messagesGetReply.status.detail).to.contain(DwnErrorCode.MessagesGetWriteRecordNotFound);
expect(messagesGetReply.status.detail).to.contain(DwnErrorCode.RecordsWriteGetNewestWriteRecordNotFound);
});
});
});
Expand Down
170 changes: 170 additions & 0 deletions tests/protocols/permissions.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit b82212b

Please sign in to comment.