Skip to content

Commit

Permalink
Authorize RecordsRead with globalRole (#512)
Browse files Browse the repository at this point in the history
* Validate  upon protocol definition ingestion

* Process creation of protocol role records

* Authorize RecordsRead with globalRole

* Move protocolRole to authorization payload

* Remove unused error code
  • Loading branch information
Diane Huxley authored Sep 25, 2023
1 parent 45c0652 commit 39cf553
Show file tree
Hide file tree
Showing 16 changed files with 701 additions and 31 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.74%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-95%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-94.2%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-97.74%25-brightgreen.svg?style=flat)
![Statements](https://img.shields.io/badge/statements-97.77%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-95.04%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-94.28%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-97.77%25-brightgreen.svg?style=flat)

- [Introduction](#introduction)
- [Installation](#installation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
},
"permissionsGrantId": {
"type": "string"
},
"protocolRole": {
"$comment": "Used in the Records interface to authorize role-authorized actions for protocol records",
"type": "string"
}
}
}
23 changes: 23 additions & 0 deletions json-schemas/interface-methods/protocol-rule-set.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,32 @@
]
}
}
},
{
"required": [
"role",
"can"
],
"properties": {
"role": {
"$comment": "Must be the protocol path of a record with either $globalRole or $contextRole set to true",
"type": "string"
},
"can": {
"type": "string",
"enum": [
"read",
"write"
]
}
}
}
]
}
},
"$globalRole": {
"$comment": "When `true`, this turns a record into `role` that may be used across contexts",
"type": "boolean"
}
},
"patternProperties": {
Expand Down
5 changes: 5 additions & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ export enum DwnErrorCode {
PrivateKeySignerUnableToDeduceKeyId = 'PrivateKeySignerUnableToDeduceKeyId',
PrivateKeySignerUnsupportedCurve = 'PrivateKeySignerUnsupportedCurve',
ProtocolAuthorizationActionNotAllowed = 'ProtocolAuthorizationActionNotAllowed',
ProtocolAuthorizationDuplicateGlobalRoleRecipient = 'ProtocolAuthorizationDuplicateGlobalRoleRecipient',
ProtocolAuthorizationIncorrectDataFormat = 'ProtocolAuthorizationIncorrectDataFormat',
ProtocolAuthorizationIncorrectProtocolPath = 'ProtocolAuthorizationIncorrectProtocolPath',
ProtocolAuthorizationInvalidSchema = 'ProtocolAuthorizationInvalidSchema',
ProtocolAuthorizationInvalidType = 'ProtocolAuthorizationInvalidType',
ProtocolAuthorizationMissingRole = 'ProtocolAuthorizationMissingRole',
ProtocolAuthorizationMissingRuleSet = 'ProtocolAuthorizationMissingRuleSet',
ProtocolAuthorizationNotARole = 'ProtocolAuthorizationNotARole',
ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath = 'ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath',
ProtocolsConfigureInvalidRole = 'ProtocolsConfigureInvalidRole',
ProtocolsConfigureUnauthorized = 'ProtocolsConfigureUnauthorized',
ProtocolsQueryUnauthorized = 'ProtocolsQueryUnauthorized',
RecordsDecryptNoMatchingKeyEncryptedFound = 'RecordsDecryptNoMatchingKeyEncryptedFound',
Expand Down
4 changes: 2 additions & 2 deletions src/core/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,11 @@ export abstract class Message<M extends GenericMessage> {
public static async signAsAuthorization(
descriptor: Descriptor,
signatureInput: Signer,
permissionsGrantId?: string,
additionalPayloadProperties?: { permissionsGrantId?: string, protocolRole?: string }
): Promise<GeneralJws> {
const descriptorCid = await Cid.computeCid(descriptor);

const authPayload: BaseAuthorizationPayload = { descriptorCid, permissionsGrantId };
const authPayload: BaseAuthorizationPayload = { descriptorCid, ...additionalPayloadProperties };
removeUndefinedProperties(authPayload);
const authPayloadStr = JSON.stringify(authPayload);
const authPayloadBytes = new TextEncoder().encode(authPayloadStr);
Expand Down
166 changes: 147 additions & 19 deletions src/core/protocol-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export class ProtocolAuthorization {
protocolDefinition,
);

// If the incoming message has `protocolRole` in the descriptor, validate the invoked role
await ProtocolAuthorization.verifyInvokedRole(
tenant,
incomingMessage,
recordsWrite,
protocolDefinition,
messageStore,
);

// verify method invoked against the allowed actions
await ProtocolAuthorization.verifyAllowedActions(
tenant,
Expand All @@ -68,6 +77,14 @@ export class ProtocolAuthorization {
messageStore,
);

// If the incoming message is writing a $globalRole record, validate that the recipient is unique
await ProtocolAuthorization.verifyUniqueRoleRecipient(
tenant,
incomingMessage,
inboundMessageRuleSet,
messageStore,
);

// verify allowed condition of incoming message
await ProtocolAuthorization.verifyActionCondition(tenant, incomingMessage, messageStore);
}
Expand Down Expand Up @@ -153,26 +170,13 @@ export class ProtocolAuthorization {
protocolDefinition: ProtocolDefinition,
): ProtocolRuleSet {
const protocolPath = recordsWrite.message.descriptor.protocolPath!;
const protocolPathArray = protocolPath.split('/');

// traverse rule sets using protocolPath
let currentRuleSet: ProtocolRuleSet = protocolDefinition.structure;
let i = 0;
while (i < protocolPathArray.length) {
const currentTypeName = protocolPathArray[i];
const nextRuleSet: ProtocolRuleSet | undefined = currentRuleSet[currentTypeName];

if (nextRuleSet === undefined) {
const partialProtocolPath = protocolPathArray.slice(0, i + 1).join('/');
throw new DwnError(DwnErrorCode.ProtocolAuthorizationMissingRuleSet,
`No rule set defined for protocolPath ${partialProtocolPath}`);
}

currentRuleSet = nextRuleSet;
i++;
const ruleSet = ProtocolAuthorization.getRuleSetAtProtocolPath(protocolPath, protocolDefinition);
if (ruleSet === undefined) {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationMissingRuleSet,
`No rule set defined for protocolPath ${protocolPath}`);
}

return currentRuleSet;
return ruleSet;
}

/**
Expand Down Expand Up @@ -257,6 +261,54 @@ export class ProtocolAuthorization {
}
}

/**
* Check if the incoming message is invoking a role. If so, validate the invoked role.
*/
private static async verifyInvokedRole(
tenant: string,
incomingMessage: RecordsRead | RecordsWrite,
recordsWrite: RecordsWrite,
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
if (protocolRole === undefined) {
return;
}

const roleRuleSet = ProtocolAuthorization.getRuleSetAtProtocolPath(protocolRole, protocolDefinition);
if (roleRuleSet?.$globalRole === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationNotARole,
`Protocol path ${protocolRole} is not a valid protocolRole`
);
}

const roleRecordFilter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : recordsWrite.message.descriptor.protocol!,
protocolPath : protocolRole,
recipient : incomingMessage.author!,
isLatestBaseState : true,
};
const { messages: matchingMessages } = await messageStore.query(tenant, [roleRecordFilter]);

if (matchingMessages.length === 0) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationMissingRole,
`No role record found for protocol path ${protocolRole}`
);
}
}

/**
* Verifies the actions specified in the given message matches the allowed actions in the rule set.
* @throws {Error} if action not allowed.
Expand Down Expand Up @@ -294,12 +346,26 @@ 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;
}

for (const actionRule of actionRules) {
if (actionRule.can !== inboundMessageAction) {
continue;
}

if (actionRule.who === ProtocolActor.Anyone) {
if (invokedRole !== undefined) {
// When a protocol role is being invoked, we require that there is a matching `role` rule.
if (actionRule.role === invokedRole) {
// role is successfully invoked
return;
} else {
continue;
}
} else if (actionRule.who === ProtocolActor.Anyone) {
return;
} else if (author === undefined) {
continue;
Expand All @@ -315,6 +381,49 @@ export class ProtocolAuthorization {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationActionNotAllowed, `inbound message action ${inboundMessageAction} not allowed for author`);
}

/**
* Verifies that writes to a $globalRole record do not have the same recipient as an existing RecordsWrite
* to the same $globalRole.
*/
private static async verifyUniqueRoleRecipient(
tenant: string,
incomingMessage: RecordsRead | RecordsWrite,
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 protocolPath = incomingRecordsWrite.message.descriptor.protocolPath!;
const filter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
isLatestBaseState : true,
protocol : incomingRecordsWrite.message.descriptor.protocol!,
protocolPath,
recipient,
};
const { messages: matchingMessages } = await messageStore.query(tenant, [filter]);
const matchingRecords = matchingMessages as RecordsWriteMessage[];
const matchingRecordsExceptIncomingRecordId = matchingRecords.filter((recordsWriteMessage) =>
recordsWriteMessage.recordId !== incomingRecordsWrite.message.recordId
);
if (matchingRecordsExceptIncomingRecordId.length > 0) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationDuplicateGlobalRoleRecipient,
`DID '${recipient}' is already recipient of a $globalRole record at protocol path '${protocolPath}`
);
}
}

/**
* Verifies if the desired action can be taken.
* Currently the only check is: if the write is not the initial write, the author must be the same as the initial write
Expand Down Expand Up @@ -343,6 +452,25 @@ export class ProtocolAuthorization {
}
}

private static getRuleSetAtProtocolPath(protocolPath: string, protocolDefinition: ProtocolDefinition): ProtocolRuleSet | undefined {
const protocolPathArray = protocolPath.split('/');
let currentRuleSet: ProtocolRuleSet = protocolDefinition.structure;
let i = 0;
while (i < protocolPathArray.length) {
const currentTypeName = protocolPathArray[i];
const nextRuleSet: ProtocolRuleSet | undefined = currentRuleSet[currentTypeName];

if (nextRuleSet === undefined) {
return undefined;
}

currentRuleSet = nextRuleSet;
i++;
}

return currentRuleSet;
}

/**
* Checks if there is a RecordsWriteMessage in the ancestor chain that matches the protocolPath in given ProtocolActionRule.
* Assumes that the actionRule authorizes either recipient or author, but not 'anyone'.
Expand Down
Loading

0 comments on commit 39cf553

Please sign in to comment.