Skip to content

Commit

Permalink
Authorize RecordsWrites with protocol roles
Browse files Browse the repository at this point in the history
  • Loading branch information
Diane Huxley committed Sep 29, 2023
1 parent 9e86e41 commit f076455
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 22 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Here's to a thrilling Hacktoberfest voyage with us! 🎉
# Decentralized Web Node (DWN) SDK <!-- omit in toc -->

Code Coverage
![Statements](https://img.shields.io/badge/statements-97.78%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-95.05%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-94.26%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-97.78%25-brightgreen.svg?style=flat)
![Statements](https://img.shields.io/badge/statements-97.78%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-94.92%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-94.26%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-97.78%25-brightgreen.svg?style=flat)

- [Introduction](#introduction)
- [Installation](#installation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
},
"permissionsGrantId": {
"type": "string"
},
"protocolRole": {
"type": "string"
}
}
}
1 change: 1 addition & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export enum DwnErrorCode {
ProtocolAuthorizationMissingRole = 'ProtocolAuthorizationMissingRole',
ProtocolAuthorizationMissingRuleSet = 'ProtocolAuthorizationMissingRuleSet',
ProtocolAuthorizationNotARole = 'ProtocolAuthorizationNotARole',
ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient',
ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath = 'ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath',
ProtocolsConfigureInvalidRole = 'ProtocolsConfigureInvalidRole',
ProtocolsConfigureInvalidActionMissingOf = 'ProtocolsConfigureInvalidActionMissingOf',
Expand Down
25 changes: 9 additions & 16 deletions src/core/protocol-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,6 @@ export class ProtocolAuthorization {
protocolDefinition: ProtocolDefinition,
messageStore: MessageStore,
): Promise<void> {
// Currently only RecordsReads may invoke a role
if (incomingMessage.message.descriptor.method !== DwnMethodName.Read) {
return;
}

const protocolRole = (incomingMessage as RecordsRead).authorizationPayload?.protocolRole;

// Only verify role if there is a role being invoked
Expand Down Expand Up @@ -347,10 +342,7 @@ export class ProtocolAuthorization {
}

// Get role being invoked. Currently only Reads support role-based authorization
let invokedRole: string | undefined;
if (incomingMessage.message.descriptor.method === DwnMethodName.Read) {
invokedRole = (incomingMessage as RecordsRead).authorizationPayload?.protocolRole;
}
const invokedRole = incomingMessage.authorizationPayload?.protocolRole;

for (const actionRule of actionRules) {
if (actionRule.can !== inboundMessageAction) {
Expand Down Expand Up @@ -391,19 +383,20 @@ export class ProtocolAuthorization {
inboundMessageRuleSet: ProtocolRuleSet,
messageStore: MessageStore,
): Promise<void> {
if (incomingMessage.message.descriptor.method !== DwnMethodName.Write) {
return;
}

const incomingRecordsWrite = incomingMessage as RecordsWrite;
if (!inboundMessageRuleSet.$globalRole) {
return;
}

// FIXME(diehuxx): do we enforce presence of recipient for protocol records? I thought we required it
const recipient = incomingRecordsWrite.message.descriptor.recipient!;
const recipient = incomingRecordsWrite.message.descriptor.recipient;
if (recipient === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationRoleMissingRecipient,
'Role records must have a recipient'
);
}
const protocolPath = incomingRecordsWrite.message.descriptor.protocolPath!;
const filter = {
const filter: Filter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
isLatestBaseState : true,
Expand Down
16 changes: 11 additions & 5 deletions src/interfaces/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type RecordsWriteOptions = {
recipient?: string;
protocol?: string;
protocolPath?: string;
protocolRole?: string;
contextId?: string;
schema?: string;
recordId?: string;
Expand Down Expand Up @@ -267,7 +268,7 @@ export class RecordsWrite {
const recordsWrite = new RecordsWrite(message);

if (options.authorizationSigner !== undefined) {
await recordsWrite.sign(options.authorizationSigner, options.permissionsGrantId);
await recordsWrite.sign(options.authorizationSigner, { permissionsGrantId: options.permissionsGrantId, protocolRole: options.protocolRole });
}

return recordsWrite;
Expand Down Expand Up @@ -361,7 +362,7 @@ export class RecordsWrite {
/**
* Signs the RecordsWrite.
*/
public async sign(signer: Signer, permissionsGrantId?: string): Promise<void> {
public async sign(signer: Signer, options?: { permissionsGrantId?: string, protocolRole?: string }): Promise<void> {
const author = Jws.extractDid(signer.keyId);

const descriptor = this._message.descriptor;
Expand All @@ -383,7 +384,7 @@ export class RecordsWrite {
this._message.attestation,
this._message.encryption,
signer,
permissionsGrantId
options
);

this._message.authorization = authorization;
Expand Down Expand Up @@ -650,7 +651,7 @@ export class RecordsWrite {
attestation: GeneralJws | undefined,
encryption: EncryptionProperty | undefined,
signer: Signer,
permissionsGrantId: string | undefined,
additionalProperties?: { permissionsGrantId?: string, protocolRole?: string },
): Promise<AuthorizationModel> {
const authorizationPayload: RecordsWriteAuthorSignaturePayload = {
recordId,
Expand All @@ -663,7 +664,12 @@ export class RecordsWrite {
if (contextId !== undefined) { authorizationPayload.contextId = contextId; } // assign `contextId` only if it is defined
if (attestationCid !== undefined) { authorizationPayload.attestationCid = attestationCid; } // assign `attestationCid` only if it is defined
if (encryptionCid !== undefined) { authorizationPayload.encryptionCid = encryptionCid; } // assign `encryptionCid` only if it is defined
if (permissionsGrantId !== undefined) { authorizationPayload.permissionsGrantId = permissionsGrantId; }
if (additionalProperties?.permissionsGrantId !== undefined) {
authorizationPayload.permissionsGrantId = additionalProperties?.permissionsGrantId;
}
if (additionalProperties?.protocolRole !== undefined) {
authorizationPayload.protocolRole = additionalProperties?.protocolRole;
}

const authorizationPayloadBytes = Encoder.objectToBytes(authorizationPayload);

Expand Down
1 change: 1 addition & 0 deletions src/types/records-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type RecordsWriteDescriptor = {
method: DwnMethodName.Write;
protocol?: string;
protocolPath?: string;
protocolRole?: string;
recipient?: string;
schema?: string;
parentId?: string;
Expand Down
137 changes: 137 additions & 0 deletions tests/handlers/records-write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,32 @@ export function testRecordsWriteHandler(): void {
expect(updateFriendReply.status.code).to.equal(202);
});

it('rejects writes to a $globalRole is recipient is undefined', async () => {
// scenario: Alice writes a global role record with no recipient and it is rejected

const alice = await DidKeyResolver.generate();

const protocolDefinition = friendRoleProtocolDefinition;

const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({
author: alice,
protocolDefinition
});
const protocolWriteReply = await dwn.processMessage(alice.did, protocolsConfig.message);
expect(protocolWriteReply.status.code).to.equal(202);

// Alice writes a 'friend' $globalRole record with no recipient
const friendRoleRecord = await TestDataGenerator.generateRecordsWrite({
author : alice,
protocol : protocolDefinition.protocol,
protocolPath : 'friend',
data : new TextEncoder().encode('Bob is my friend'),
});
const friendRoleReply = await dwn.processMessage(alice.did, friendRoleRecord.message, friendRoleRecord.dataStream);
expect(friendRoleReply.status.code).to.equal(401);
expect(friendRoleReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationRoleMissingRecipient);
});

it('rejects writes to a $globalRole if there is already a record with the same role and recipient', async () => {
// scenario: Alice adds Bob to the 'friend' role. Then she tries and fails to write another separate record
// adding Bob as a 'friend' again.
Expand Down Expand Up @@ -1156,6 +1182,117 @@ export function testRecordsWriteHandler(): void {
expect(duplicateFriendReply.status.code).to.equal(202);
});
});

describe('protocolRole based writes', () => {
it('uses a protocolRole to authorize a write', async () => {
// scenario: Alice gives Bob a friend role. Bob invokes his
// friend role in order to write a chat message

const alice = await DidKeyResolver.generate();
const bob = await DidKeyResolver.generate();

const protocolDefinition = friendRoleProtocolDefinition;

const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({
author: alice,
protocolDefinition
});
const protocolWriteReply = await dwn.processMessage(alice.did, protocolsConfig.message);
expect(protocolWriteReply.status.code).to.equal(202);

// Alice writes a 'friend' $globalRole record with Bob as recipient
const friendRoleRecord = await TestDataGenerator.generateRecordsWrite({
author : alice,
recipient : bob.did,
protocol : protocolDefinition.protocol,
protocolPath : 'friend',
data : new TextEncoder().encode('Bob is my friend'),
});
const friendRoleReply = await dwn.processMessage(alice.did, friendRoleRecord.message, friendRoleRecord.dataStream);
expect(friendRoleReply.status.code).to.equal(202);

// Bob writes a 'chat' record
const chatRecord = await TestDataGenerator.generateRecordsWrite({
author : bob,
recipient : alice.did,
protocol : protocolDefinition.protocol,
protocolPath : 'chat',
data : new TextEncoder().encode('Bob can write this cuz he is Alices friend'),
protocolRole : 'friend'
});
const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream);
expect(chatReply.status.code).to.equal(202);
});

it('rejects role-authorized writes if the protocolRole is not a valid protocol path to a role record', async () => {
// scenario: Bob tries to invoke the 'chat' role to write to Alice's DWN, but 'chat' is not a role.

const alice = await DidKeyResolver.generate();
const bob = await DidKeyResolver.generate();

const protocolDefinition = friendRoleProtocolDefinition;

const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({
author: alice,
protocolDefinition
});
const protocolWriteReply = await dwn.processMessage(alice.did, protocolsConfig.message);
expect(protocolWriteReply.status.code).to.equal(202);

// Alice writes a 'chat' record with Bob as recipient
const chatRecord = await TestDataGenerator.generateRecordsWrite({
author : alice,
recipient : bob.did,
protocol : protocolDefinition.protocol,
protocolPath : 'chat',
data : new TextEncoder().encode('Blah blah blah'),
});
const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream);
expect(chatReply.status.code).to.equal(202);

// Bob tries to invoke a 'chat' role but 'chat' is not a role
const writeChatRecord = await TestDataGenerator.generateRecordsWrite({
author : bob,
recipient : bob.did,
protocol : protocolDefinition.protocol,
protocolPath : 'chat',
data : new TextEncoder().encode('Blah blah blah'),
protocolRole : 'chat',
});
const chatReadReply = await dwn.processMessage(alice.did, writeChatRecord.message, writeChatRecord.dataStream);
expect(chatReadReply.status.code).to.equal(401);
expect(chatReadReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationNotARole);
});

it('rejects role-authorized writes if there is no active role for the recipient', async () => {
// scenario: Bob tries to invoke a role to write, but he has not been given one.

const alice = await DidKeyResolver.generate();
const bob = await DidKeyResolver.generate();

const protocolDefinition = friendRoleProtocolDefinition;

const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({
author: alice,
protocolDefinition
});
const protocolWriteReply = await dwn.processMessage(alice.did, protocolsConfig.message);
expect(protocolWriteReply.status.code).to.equal(202);

// Bob writes a 'chat' record invoking a friend role that he does not have
const chatRecord = await TestDataGenerator.generateRecordsWrite({
author : bob,
recipient : bob.did,
protocol : protocolDefinition.protocol,
protocolPath : 'chat',
data : new TextEncoder().encode('Blah blah blah'),
protocolRole : 'friend'
});
const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream);
expect(chatReply.status.code).to.equal(401);
expect(chatReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingRole);
});
});
});

it('should allow overwriting records by the same author', async () => {
Expand Down
2 changes: 2 additions & 0 deletions tests/utils/test-data-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type GenerateRecordsWriteInput = {
recipient?: string;
protocol?: string;
protocolPath?: string;
protocolRole?: string;
contextId?: string;
schema?: string;
recordId?: string;
Expand Down Expand Up @@ -399,6 +400,7 @@ export class TestDataGenerator {
recipient : input?.recipient,
protocol : input?.protocol,
protocolPath : input?.protocolPath,
protocolRole : input?.protocolRole,
contextId : input?.contextId,
schema : input?.schema ?? `http://${TestDataGenerator.randomString(20)}`,
recordId : input?.recordId,
Expand Down

0 comments on commit f076455

Please sign in to comment.