Skip to content

Commit

Permalink
#564 - Added delegated grant protocol scope for RecordsRead, RecordsQ…
Browse files Browse the repository at this point in the history
…uery, & RecordsDelete (#644)
  • Loading branch information
thehenrytsai authored Dec 8, 2023
1 parent b8901e9 commit 41d3550
Show file tree
Hide file tree
Showing 20 changed files with 496 additions and 98 deletions.
5 changes: 4 additions & 1 deletion json-schemas/permissions/permissions-definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
},
{
"$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-write-scope"
},
{
"$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-query-scope"
}
]
},
Expand All @@ -42,4 +45,4 @@
}
}
}
}
}
23 changes: 22 additions & 1 deletion json-schemas/permissions/scopes.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
"method": {
"const": "Delete"
},
"protocol": {
"type": "string"
},
"schema": {
"type": "string"
}
Expand Down Expand Up @@ -85,6 +88,24 @@
"type": "string"
}
}
},
"records-query-scope": {
"type": "object",
"required": [
"interface",
"method"
],
"properties": {
"interface": {
"const": "Records"
},
"method": {
"const": "Query"
},
"protocol": {
"type": "string"
}
}
}
}
}
}
9 changes: 8 additions & 1 deletion src/core/abstract-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export abstract class AbstractMessage<M extends GenericMessage> implements Messa
return this._message as M;
}

private _signer: string | undefined;
public get signer(): string | undefined {
return this._signer;
}

private _author: string | undefined;
public get author(): string | undefined {
return this._author;
Expand All @@ -27,12 +32,14 @@ export abstract class AbstractMessage<M extends GenericMessage> implements Messa
this._message = message;

if (message.authorization !== undefined) {
this._signer = Message.getSigner(message);

// if the message authorization contains author delegated grant, the author would be the grantor of the grant
// else the author would be the signer of the message
if (message.authorization.authorDelegatedGrant !== undefined) {
this._author = Message.getSigner(message.authorization.authorDelegatedGrant);
} else {
this._author = Message.getSigner(message as GenericMessage);
this._author = this._signer;
}

this._signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature);
Expand Down
2 changes: 2 additions & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export enum DwnErrorCode {
RecordsQueryParseFilterPublishedSortInvalid = 'RecordsQueryParseFilterPublishedSortInvalid',
RecordsGrantAuthorizationConditionPublicationProhibited = 'RecordsGrantAuthorizationConditionPublicationProhibited',
RecordsGrantAuthorizationConditionPublicationRequired = 'RecordsGrantAuthorizationConditionPublicationRequired',
RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch',
RecordsGrantAuthorizationQueryProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryProtocolScopeMismatch',
RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch',
RecordsGrantAuthorizationScopeNotProtocol = 'RecordsGrantAuthorizationScopeNotProtocol',
RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch',
Expand Down
38 changes: 25 additions & 13 deletions src/core/grant-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ export class GrantAuthorization {
/**
* Performs PermissionsGrant-based authorization against the given message
* Does not validate grant `conditions` or `scope` beyond `interface` and `method`
* @param messageStore Used to check if the grant has been revoked.
* @throws {DwnError} if authorization fails
*/
public static async authorizeGenericMessage(
public static async authorizeGenericMessage(input: {
tenant: string,
incomingMessage: GenericMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
}): Promise<void> {
const { tenant, incomingMessage, author, permissionsGrantMessage, messageStore } = input;

const incomingMessageDescriptor = incomingMessage.descriptor;
const permissionsGrantId = await Message.getCid(permissionsGrantMessage);

GrantAuthorization.verifyGrantedToAndGrantedFor(author, tenant, permissionsGrantMessage);
const expectedGrantedToInGrant = author;
const expectedGrantedForInGrant = tenant;
GrantAuthorization.verifyExpectedGrantedToAndGrantedFor(expectedGrantedToInGrant, expectedGrantedForInGrant, permissionsGrantMessage);

// verify that grant is active during incomingMessage's timestamp
await GrantAuthorization.verifyGrantActive(
Expand Down Expand Up @@ -70,24 +74,31 @@ export class GrantAuthorization {
}

/**
* Verifies the given `grantedTo` and `grantedFor` values against the given permissions grant and throws error if there is a mismatch.
* Verifies the given `expectedGrantedToInGrant` and `expectedGrantedForInGrant` values against
* the actual `expectedGrantedToInGrant` and `expectedGrantedForInGrant` in given permissions grant.
* @throws {DwnError} if `expectedGrantedToInGrant` or `expectedGrantedForInGrant` do not match the actual values in the grant.
*/
private static verifyGrantedToAndGrantedFor(grantedTo: string, grantedFor: string, permissionsGrantMessage: PermissionsGrantMessage): void {
// Validate `grantedTo`
const expectedGrantedTo = permissionsGrantMessage.descriptor.grantedTo;
if (expectedGrantedTo !== grantedTo) {
private static verifyExpectedGrantedToAndGrantedFor(
expectedGrantedToInGrant: string,
expectedGrantedForInGrant: string,
permissionsGrantMessage: PermissionsGrantMessage
): void {

// Validate `expectedGrantedToInGrant`
const actualGrantedTo = permissionsGrantMessage.descriptor.grantedTo;
if (expectedGrantedToInGrant !== actualGrantedTo) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationNotGrantedToAuthor,
`PermissionsGrant has grantedTo ${expectedGrantedTo}, but given ${grantedTo}`
`PermissionsGrant has grantedTo ${actualGrantedTo}, but need to be granted to ${expectedGrantedToInGrant}`
);
}

// Validate `grantedFor`
const expectedGrantedFor = permissionsGrantMessage.descriptor.grantedFor;
if (expectedGrantedFor !== grantedFor) {
// Validate `expectedGrantedForInGrant`
const actualGrantedFor = permissionsGrantMessage.descriptor.grantedFor;
if (expectedGrantedForInGrant !== actualGrantedFor) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationNotGrantedForTenant,
`PermissionsGrant has grantedFor ${expectedGrantedFor}, but given ${grantedFor}`
`PermissionsGrant has grantedFor ${actualGrantedFor}, but need to be granted for ${expectedGrantedForInGrant}`
);
}
}
Expand All @@ -96,6 +107,7 @@ export class GrantAuthorization {
* Verify that the incoming message is within the allowed time frame of the grant,
* and the grant has not been revoked.
* @param permissionsGrantId Purely being passed as an optimization. Technically can be computed from `permissionsGrantMessage`.
* @param messageStore Used to check if the grant has been revoked.
* @throws {DwnError} if incomingMessage has timestamp for a time in which the grant is not active.
*/
private static async verifyGrantActive(
Expand Down
7 changes: 7 additions & 0 deletions src/core/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ export class Message {
return aIsOlder;
}

/**
* See if the given message is signed by a delegate
*/
public static isSignedByDelegate(message: GenericMessage): boolean {
return message.authorization?.authorDelegatedGrant !== undefined;
}

/**
* Compares the `messageTimestamp` of the given messages with a fallback to message CID according to the spec.
* @returns 1 if `a` is larger/newer than `b`; -1 if `a` is smaller/older than `b`; 0 otherwise (same age)
Expand Down
77 changes: 69 additions & 8 deletions src/core/records-grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { MessageStore } from '../types/message-store.js';
import type { PermissionsGrantMessage } from '../types/permissions-types.js';
import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js';
import type { RecordsRead } from '../interfaces/records-read.js';
import type { RecordsWriteMessage } from '../types/records-types.js';
import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js';

import { GrantAuthorization } from './grant-authorization.js';
import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js';
Expand All @@ -19,13 +18,13 @@ export class RecordsGrantAuthorization {
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.authorizeGenericMessage({
tenant,
incomingMessage,
author,
permissionsGrantMessage,
messageStore
);
});

RecordsGrantAuthorization.verifyScope(incomingMessage, permissionsGrantMessage);

Expand All @@ -34,26 +33,88 @@ export class RecordsGrantAuthorization {

/**
* Authorizes the scope of a PermissionsGrant for RecordsRead.
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeRead(
tenant: string,
incomingMessage: RecordsRead,
incomingMessage: RecordsReadMessage,
newestRecordsWriteMessage: RecordsWriteMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.authorizeGenericMessage({
tenant,
incomingMessage.message,
incomingMessage,
author,
permissionsGrantMessage,
messageStore
);
});

RecordsGrantAuthorization.verifyScope(newestRecordsWriteMessage, permissionsGrantMessage);
}

/**
* Authorizes the scope of a PermissionsGrant for RecordsQuery.
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeQuery(
tenant: string,
incomingMessage: RecordsQueryMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
await GrantAuthorization.authorizeGenericMessage({
tenant,
incomingMessage,
author,
permissionsGrantMessage,
messageStore
});

// If the grant specifies a protocol, the query must specify the same protocol.
const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol;
const protocolInQuery = incomingMessage.descriptor.filter.protocol;
if (protocolInGrant !== undefined && protocolInQuery !== protocolInGrant) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationQueryProtocolScopeMismatch,
`Grant protocol scope ${protocolInGrant} does not match protocol in query ${protocolInQuery}`
);
}
}

/**
* Authorizes the scope of a PermissionsGrant for RecordsDelete.
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeDelete(
tenant: string,
incomingDeleteMessage: RecordsDeleteMessage,
recordsWriteToDelete: RecordsWriteMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
await GrantAuthorization.authorizeGenericMessage({
tenant,
incomingMessage: incomingDeleteMessage,
author,
permissionsGrantMessage,
messageStore
});

// If the grant specifies a protocol, the delete must be deleting a record with the same protocol.
const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol;
const protocolOfRecordToDelete = recordsWriteToDelete.descriptor.protocol;
if (protocolInGrant !== undefined && protocolOfRecordToDelete !== protocolInGrant) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationDeleteProtocolScopeMismatch,
`Grant protocol scope ${protocolInGrant} does not match protocol in record to delete ${protocolOfRecordToDelete}`
);
}
}

/**
* @param recordsWrite The source of the record being authorized. If the incoming message is a write,
* then this is the incoming RecordsWrite. Otherwise, it is the newest existing RecordsWrite.
Expand Down
15 changes: 10 additions & 5 deletions src/handlers/records-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,20 @@ export class RecordsDeleteHandler implements MethodHandler {
};

private static async authorizeRecordsDelete(
tenant: string, recordsDelete:
RecordsDelete, newestRecordsWrite:
RecordsWrite, messageStore: MessageStore
tenant: string,
recordsDelete: RecordsDelete,
recordsWriteToDelete: RecordsWrite,
messageStore: MessageStore
): Promise<void> {

if (Message.isSignedByDelegate(recordsDelete.message)) {
await recordsDelete.authorizeDelegate(recordsWriteToDelete.message, messageStore);
}

if (recordsDelete.author === tenant) {
return;
} else if (newestRecordsWrite.message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, newestRecordsWrite, messageStore);
} else if (recordsWriteToDelete.message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, recordsWriteToDelete, messageStore);
} else {
throw new DwnError(
DwnErrorCode.RecordsDeleteAuthorizationFailed,
Expand Down
25 changes: 21 additions & 4 deletions src/handlers/records-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry } f

import { authenticate } from '../core/auth.js';
import { DateSort } from '../types/records-types.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
import { Records } from '../utils/records.js';
Expand Down Expand Up @@ -43,10 +44,7 @@ export class RecordsQueryHandler implements MethodHandler {
try {
await authenticate(message.authorization!, this.didResolver);

// Only run protocol authz if message deliberately invokes it
if (RecordsQueryHandler.shouldProtocolAuthorizeQuery(recordsQuery)) {
await ProtocolAuthorization.authorizeQuery(tenant, recordsQuery, this.messageStore);
}
await RecordsQueryHandler.authorizeRecordsQuery(tenant, recordsQuery, this.messageStore);
} catch (e) {
return messageReplyFromError(e, 401);
}
Expand Down Expand Up @@ -267,4 +265,23 @@ export class RecordsQueryHandler implements MethodHandler {
}
return filter.published === false;
}

/**
* @param messageStore Used to check if the grant has been revoked.
*/
private static async authorizeRecordsQuery(
tenant: string,
recordsQuery: RecordsQuery,
messageStore: MessageStore
): Promise<void> {

if (Message.isSignedByDelegate(recordsQuery.message)) {
await recordsQuery.authorizeDelegate(messageStore);
}

// Only run protocol authz if message deliberately invokes it
if (RecordsQueryHandler.shouldProtocolAuthorizeQuery(recordsQuery)) {
await ProtocolAuthorization.authorizeQuery(tenant, recordsQuery, messageStore);
}
}
}
Loading

0 comments on commit 41d3550

Please sign in to comment.