Skip to content

Commit

Permalink
Removed all references of $globalRole and $contextRole (#692)
Browse files Browse the repository at this point in the history
The distinction of "global role" and "context role" is still useful when
discussing placement and usage, but changed the code to go through the
same path (except edge case handling).

Added a validation (and test) to make sure protocol structure always
references declared record types.

This "should" wrap up the generalization of roles.
  • Loading branch information
thehenrytsai committed Feb 21, 2024
1 parent a842ef7 commit e616138
Show file tree
Hide file tree
Showing 18 changed files with 234 additions and 263 deletions.
7 changes: 7 additions & 0 deletions Q_AND_A.md
Expand Up @@ -101,6 +101,13 @@
- `write` - allows a DID to create and update the record they have created
- `update` - allows a DID to update a record, regardless of the initial author

- What is the difference between the terms "global role" and "context role"?

(Last update: 2024/02/16)

The structure and usage of "global roles" and "context roles" are identical. The distinction lies in their placement within the protocol hierarchy: a "global role" is defined as a root level record without a parent record, whereas a "context role" has a parent record thus making it "contextual". This means that a protocol rule set can have access to all global role records/assignments, hence the "global" designation; conversely, access to context role records/assignment is restricted to the specific context relevant to the message being authorized.


## Subscriptions
- What happens to a subscription which is listening to events, but is no longer authorized due to revocation of a grant or role?

Expand Down
12 changes: 4 additions & 8 deletions json-schemas/interface-methods/protocol-rule-set.json
Expand Up @@ -58,7 +58,7 @@
],
"properties": {
"role": {
"$comment": "Must be the protocol path of a record with either $globalRole or $contextRole set to true",
"$comment": "Must be the protocol path of a role record type",
"type": "string"
},
"can": {
Expand All @@ -77,12 +77,8 @@
]
}
},
"$globalRole": {
"$comment": "When `true`, this turns a record into `role` that may be used across contexts",
"type": "boolean"
},
"$contextRole": {
"$comment": "When `true`, this turns a record into `role` that may be used within a context",
"$role": {
"$comment": "When `true`, this turns a record into `role` that may be used within a context/sub-context",
"type": "boolean"
},
"$size": {
Expand All @@ -105,4 +101,4 @@
"$ref": "https://identity.foundation/dwn/json-schemas/protocol-rule-set.json"
}
}
}
}
7 changes: 3 additions & 4 deletions src/core/dwn-error.ts
Expand Up @@ -59,11 +59,10 @@ export enum DwnErrorCode {
PrivateKeySignerUnsupportedCurve = 'PrivateKeySignerUnsupportedCurve',
ProtocolAuthorizationActionNotAllowed = 'ProtocolAuthorizationActionNotAllowed',
ProtocolAuthorizationActionRulesNotFound = 'ProtocolAuthorizationActionRulesNotFound',
ProtocolAuthorizationDuplicateContextRoleRecipient = 'ProtocolAuthorizationDuplicateContextRoleRecipient',
ProtocolAuthorizationDuplicateGlobalRoleRecipient = 'ProtocolAuthorizationDuplicateGlobalRoleRecipient',
ProtocolAuthorizationIncorrectDataFormat = 'ProtocolAuthorizationIncorrectDataFormat',
ProtocolAuthorizationIncorrectContextId = 'ProtocolAuthorizationIncorrectContextId',
ProtocolAuthorizationIncorrectProtocolPath = 'ProtocolAuthorizationIncorrectProtocolPath',
ProtocolAuthorizationDuplicateRoleRecipient = 'ProtocolAuthorizationDuplicateRoleRecipient',
ProtocolAuthorizationInvalidSchema = 'ProtocolAuthorizationInvalidSchema',
ProtocolAuthorizationInvalidType = 'ProtocolAuthorizationInvalidType',
ProtocolAuthorizationMatchingRoleRecordNotFound = 'ProtocolAuthorizationMatchingRoleRecordNotFound',
Expand All @@ -77,14 +76,14 @@ export enum DwnErrorCode {
ProtocolAuthorizationProtocolNotFound = 'ProtocolAuthorizationProtocolNotFound',
ProtocolAuthorizationQueryWithoutRole = 'ProtocolAuthorizationQueryWithoutRole',
ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient',
ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath = 'ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath',
ProtocolsConfigureInvalidRole = 'ProtocolsConfigureInvalidRole',
ProtocolsConfigureInvalidSize = 'ProtocolsConfigureInvalidSize',
ProtocolsConfigureInvalidActionMissingOf = 'ProtocolsConfigureInvalidActionMissingOf',
ProtocolsConfigureInvalidActionOfNotAllowed = 'ProtocolsConfigureInvalidActionOfNotAllowed',
ProtocolsConfigureInvalidRecipientOfAction = 'ProtocolsConfigureInvalidRecipientOfAction',
ProtocolsConfigureInvalidRuleSetRecordType = 'ProtocolsConfigureInvalidRuleSetRecordType',
ProtocolsConfigureQueryNotAllowed = 'ProtocolsConfigureQueryNotAllowed',
ProtocolsConfigureRecordNestingDepthExceeded = 'ProtocolsConfigureRecordNestingDepthExceeded',
ProtocolsConfigureRoleDoesNotExistAtGivenPath = 'ProtocolsConfigureRoleDoesNotExistAtGivenPath',
ProtocolsConfigureUnauthorized = 'ProtocolsConfigureUnauthorized',
ProtocolsQueryUnauthorized = 'ProtocolsQueryUnauthorized',
RecordsDecryptNoMatchingKeyEncryptedFound = 'RecordsDecryptNoMatchingKeyEncryptedFound',
Expand Down
62 changes: 30 additions & 32 deletions src/core/protocol-authorization.ts
Expand Up @@ -457,13 +457,14 @@ export class ProtocolAuthorization {
}

const roleRuleSet = ProtocolAuthorization.getRuleSetAtProtocolPath(protocolRole, protocolDefinition);
if (roleRuleSet === undefined || (!roleRuleSet.$globalRole && !roleRuleSet.$contextRole)) {
if (roleRuleSet === undefined || !roleRuleSet.$role) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationNotARole,
`Protocol path ${protocolRole} is not a valid protocolRole`
`Protocol path ${protocolRole} does not match role record type.`
);
}

// Construct a filter to fetch the invoked role record
const roleRecordFilter: Filter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
Expand All @@ -473,26 +474,27 @@ export class ProtocolAuthorization {
isLatestBaseState : true,
};

if (roleRuleSet.$contextRole) {
if (contextId === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationMissingContextId,
'Could not verify $contextRole because contextId is missing'
);
}
const ancestorSegmentCountOfRolePath = protocolRole.split('/').length - 1;
if (contextId === undefined && ancestorSegmentCountOfRolePath > 0) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationMissingContextId,
'Could not verify role because contextId is missing.'
);
}

// Compute `contextId` prefix filter for fetching the invoked role record.
// e.g. if invoked role path is `Thread/Participant`, and the `contextId` of the message is `threadX/messageY/attachmentZ`,
// then we need to add a prefix filter as `threadX` for the `contextId`
// because the `contextId` of the Participant record would be in the form of be `threadX/participantA`
const ancestorSegmentCountOfRole = protocolRole.split('/').length - 1;
const contextIdSegments = contextId.split('/');
const contextIdPrefix = contextIdSegments.slice(0, ancestorSegmentCountOfRole).join('/');
// Compute `contextId` prefix filter for fetching the invoked role record if the role path is not at the root level.
// e.g. if invoked role path is `Thread/Participant`, and the `contextId` of the message is `threadX/messageY/attachmentZ`,
// then we need to add a prefix filter as `threadX` for the `contextId`
// because the `contextId` of the Participant record would be in the form of be `threadX/participantA`
if (ancestorSegmentCountOfRolePath > 0) {
const contextIdSegments = contextId!.split('/'); // NOTE: currently contextId segment count is never shorter than the role path count.
const contextIdPrefix = contextIdSegments.slice(0, ancestorSegmentCountOfRolePath).join('/');
const contextIdPrefixFilter = FilterUtility.constructPrefixFilterAsRangeFilter(contextIdPrefix);

roleRecordFilter.contextId = contextIdPrefixFilter;
}


const { messages: matchingMessages } = await messageStore.query(tenant, [roleRecordFilter]);

if (matchingMessages.length === 0) {
Expand Down Expand Up @@ -645,7 +647,7 @@ export class ProtocolAuthorization {
/**
* If the given RecordsWrite is not a role record, this method does nothing and succeeds immediately.
*
* Else it verifies the validity of the given `RecordsWrite` including:
* Else it verifies the validity of the given `RecordsWrite` as a role record, including:
* 1. The same role has not been assigned to the same entity/recipient.
*/
private static async verifyAsRoleRecordIfNeeded(
Expand All @@ -654,10 +656,12 @@ export class ProtocolAuthorization {
inboundMessageRuleSet: ProtocolRuleSet,
messageStore: MessageStore,
): Promise<void> {
if (!inboundMessageRuleSet.$globalRole && !inboundMessageRuleSet.$contextRole) {
if (!inboundMessageRuleSet.$role) {
return;
}

// else this is a role record

const incomingRecordsWrite = incomingMessage;
const recipient = incomingRecordsWrite.message.descriptor.recipient;
if (recipient === undefined) {
Expand All @@ -677,8 +681,10 @@ export class ProtocolAuthorization {
recipient,
};

if (inboundMessageRuleSet.$contextRole) {
const parentContextId = Records.getParentContextFromOfContextId(incomingRecordsWrite.message.contextId)!;
const parentContextId = Records.getParentContextFromOfContextId(incomingRecordsWrite.message.contextId)!;

// if this is not the root record, add a prefix filter to the query
if (parentContextId !== '') {
const prefixFilter = FilterUtility.constructPrefixFilterAsRangeFilter(parentContextId);
filter.contextId = prefixFilter;
}
Expand All @@ -689,18 +695,10 @@ export class ProtocolAuthorization {
recordsWriteMessage.recordId !== incomingRecordsWrite.message.recordId
);
if (matchingRecordsExceptIncomingRecordId.length > 0) {
if (inboundMessageRuleSet.$globalRole) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationDuplicateGlobalRoleRecipient,
`DID '${recipient}' is already recipient of a $globalRole record at protocol path '${protocolPath}`
);
} else {
// $contextRole
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationDuplicateContextRoleRecipient,
`DID '${recipient}' is already recipient of a $contextRole record at protocol path '${protocolPath} in the same context`
);
}
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationDuplicateRoleRecipient,
`DID '${recipient}' is already recipient of a role record at protocol path '${protocolPath} under the parent context ${parentContextId}.`
);
}
}

Expand Down
109 changes: 67 additions & 42 deletions src/interfaces/protocols-configure.ts
Expand Up @@ -48,6 +48,9 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
return protocolsConfigure;
}

/**
* Performs validation on the given protocol definition that are not easy to do using a JSON schema.
*/
private static validateProtocolDefinition(definition: ProtocolDefinition): void {
const { protocol, types } = definition;

Expand All @@ -67,34 +70,30 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
}

private static validateStructure(definition: ProtocolDefinition): void {
// gather $globalRoles
const globalRoles: string[] = [];
for (const rootRecordPath in definition.structure) {
const rootRuleSet = definition.structure[rootRecordPath];
if (rootRuleSet.$globalRole) {
globalRoles.push(rootRecordPath);
}
}

// Traverse nested rule sets
for (const rootRecordPath in definition.structure) {
const rootRuleSet = definition.structure[rootRecordPath];
// gather all declared record types
const recordTypes = Object.keys(definition.types);

// gather $contextRoles
const contextRoles = ProtocolsConfigure.fetchAllContextRolePathsRecursively(rootRecordPath, rootRuleSet, []);
// gather all roles
const roles = ProtocolsConfigure.fetchAllRolePathsRecursively('', definition.structure, []);

ProtocolsConfigure.validateRuleSetRecursively(rootRuleSet, rootRecordPath, [...globalRoles, ...contextRoles]);
}
// validate the entire rule set structure recursively
ProtocolsConfigure.validateRuleSetRecursively({
ruleSet : definition.structure,
ruleSetProtocolPath : '',
recordTypes,
roles
});
}

/**
* Parses the given rule set hierarchy to get all the context role protocol paths.
* Parses the given rule set hierarchy to get all the role protocol paths.
* @throws DwnError if the hierarchy depth goes beyond 10 levels.
*/
private static fetchAllContextRolePathsRecursively(recordProtocolPath: string, ruleSet: ProtocolRuleSet, contextRoles: string[]): string[] {
private static fetchAllRolePathsRecursively(ruleSetProtocolPath: string, ruleSet: ProtocolRuleSet, roles: string[]): string[] {
// Limit the depth of the record hierarchy to 10 levels
// There is opportunity to optimize here to avoid repeated string splitting
if (recordProtocolPath.split('/').length > 10) {
if (ruleSetProtocolPath.split('/').length > 10) {
throw new DwnError(DwnErrorCode.ProtocolsConfigureRecordNestingDepthExceeded, 'Record nesting depth exceeded 10 levels.');
}

Expand All @@ -105,30 +104,33 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
}

const childRuleSet = ruleSet[recordType];
const childProtocolPath = `${recordProtocolPath}/${recordType}`;

let childRuleSetProtocolPath;
if (ruleSetProtocolPath === '') {
childRuleSetProtocolPath = recordType;
} else {
childRuleSetProtocolPath = `${ruleSetProtocolPath}/${recordType}`;
}

// if this is a role record, add it to the list, else continue to traverse
if (childRuleSet.$contextRole) {
contextRoles.push(childProtocolPath);
if (childRuleSet.$role) {
roles.push(childRuleSetProtocolPath);
} else {
ProtocolsConfigure.fetchAllContextRolePathsRecursively(childProtocolPath, childRuleSet, contextRoles);
ProtocolsConfigure.fetchAllRolePathsRecursively(childRuleSetProtocolPath, childRuleSet, roles);
}
}

return contextRoles;
return roles;
}

/**
* Validates the given rule set structure then recursively validates its nested child rule sets.
*/
private static validateRuleSetRecursively(ruleSet: ProtocolRuleSet, protocolPath: string, roles: string[]): void {
const depth = protocolPath.split('/').length;
if (ruleSet.$globalRole && depth !== 1) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureGlobalRoleAtProhibitedProtocolPath,
`$globalRole is not allowed at protocol path (${protocolPath}). Only root records may set $globalRole true.`
);
}
private static validateRuleSetRecursively(
input: { ruleSet: ProtocolRuleSet, ruleSetProtocolPath: string, recordTypes: string[], roles: string[] }
): void {

const { ruleSet, ruleSetProtocolPath, recordTypes, roles } = input;

// Validate $actions in the rule set
if (ruleSet.$size !== undefined) {
Expand All @@ -137,27 +139,30 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
if (max !== undefined && max < min) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidSize,
`Invalid size range found: max limit ${max} less than min limit ${min} at protocol path '${protocolPath}'`
`Invalid size range found: max limit ${max} less than min limit ${min} at protocol path '${ruleSetProtocolPath}'`
);
}
}

// Validate $actions in the rule set
const actions = ruleSet.$actions ?? [];
for (const action of actions) {
// Validate that all `role` properties contain protocol paths $globalRole or $contextRole records
if (action.role !== undefined && !roles.includes(action.role)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidRole,
`Invalid role '${action.role}' found at protocol path '${protocolPath}'`
);
// Validate the `role` property of an `action` if exists.
if (action.role !== undefined) {
// make sure the role contains a valid protocol paths to a role record
if (!roles.includes(action.role)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureRoleDoesNotExistAtGivenPath,
`Role in action ${JSON.stringify(action)} for rule set ${ruleSetProtocolPath} does not exist.`
);
}
}

// Validate that if `who` is set to `anyone` then `of` is not set
if (action.who === 'anyone' && action.of) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidActionOfNotAllowed,
`'of' is not allowed at protocol path (${protocolPath})`
`'of' is not allowed at rule set protocol path (${ruleSetProtocolPath})`
);
}

Expand Down Expand Up @@ -191,9 +196,29 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
if (recordType.startsWith('$')) {
continue;
}
const rootRuleSet = ruleSet[recordType];
const nextProtocolPath = `${protocolPath}/${recordType}`;
ProtocolsConfigure.validateRuleSetRecursively(rootRuleSet, nextProtocolPath, roles);

if (!recordTypes.includes(recordType)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidRuleSetRecordType,
`Rule set ${recordType} is not declared as an allowed type in the protocol definition.`
);
}

const childRuleSet = ruleSet[recordType];

let childRuleSetProtocolPath;
if (ruleSetProtocolPath === '') {
childRuleSetProtocolPath = recordType; // case of initial definition structure
} else {
childRuleSetProtocolPath = `${ruleSetProtocolPath}/${recordType}`;
}

ProtocolsConfigure.validateRuleSetRecursively({
ruleSet : childRuleSet,
ruleSetProtocolPath : childRuleSetProtocolPath,
recordTypes,
roles
});
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/records-read.ts
Expand Up @@ -18,7 +18,7 @@ export type RecordsReadOptions = {
permissionsGrantId?: string;
/**
* Used when authorizing protocol records.
* The protocol path to a $globalRole record whose recipient is the author of this RecordsRead
* The protocol path to the role record type whose recipient is the author of this RecordsRead
*/
protocolRole?: string;

Expand Down

0 comments on commit e616138

Please sign in to comment.