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

Authorize RecordsWrites with protocol roles #524

Merged
merged 8 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
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
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
![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.8%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-95.12%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.8%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
26 changes: 9 additions & 17 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 @@ -346,11 +341,7 @@ export class ProtocolAuthorization {
throw new Error(`no action rule defined for ${incomingMessage.message.descriptor.method}, ${author} is unauthorized`);
}

// 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 +382,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
30 changes: 16 additions & 14 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 All @@ -399,7 +400,7 @@ export class RecordsWrite {
await ProtocolAuthorization.authorize(tenant, this, this, messageStore);
} else if (this.author === tenant) {
// if author is the same as the target tenant, we can directly grant access
} else if (this.author !== undefined && this.authorizationPayload?.permissionsGrantId !== undefined) {
} else if (this.author !== undefined && this.authorizationPayload!.permissionsGrantId !== undefined) {
diehuxx marked this conversation as resolved.
Show resolved Hide resolved
await RecordsGrantAuthorization.authorizeWrite(tenant, this, this.author, messageStore);
} else {
throw new Error('message failed authorization');
Expand Down Expand Up @@ -650,20 +651,21 @@ 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,
descriptorCid
};

const attestationCid = attestation ? await Cid.computeCid(attestation) : undefined;
const encryptionCid = encryption ? await Cid.computeCid(encryption) : undefined;

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; }
const authorizationPayload: RecordsWriteAuthorSignaturePayload = {
recordId,
descriptorCid,
contextId,
attestationCid,
encryptionCid,
permissionsGrantId : additionalProperties?.permissionsGrantId,
protocolRole : additionalProperties?.protocolRole
};
removeUndefinedProperties(authorizationPayload);
diehuxx marked this conversation as resolved.
Show resolved Hide resolved

const authorizationPayloadBytes = Encoder.objectToBytes(authorizationPayload);

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
7 changes: 7 additions & 0 deletions tests/vectors/protocol-definitions/friend-role.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@
"friend": {
"$globalRole": true
},
"fan": {
"$globalRole": true
},
"chat": {
"$actions": [
{
"role": "fan",
"can": "read"
},
{
"role": "friend",
"can": "write"
Expand Down