Skip to content

Commit

Permalink
#564 - Added delegated grant scoping for RecordsWrite (#617)
Browse files Browse the repository at this point in the history
* Added delegated grant scoping for RecordsWrite.
* Refactored code so that delegated grant evaluation can reuse the permissions grant evaluation logic.
  • Loading branch information
thehenrytsai authored Nov 27, 2023
1 parent 597c102 commit 066e74b
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 45 deletions.
24 changes: 10 additions & 14 deletions src/core/grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { GenericMessage } from '../types/message-types.js';
import type { MessageInterface } from '../types/message-interface.js';
import type { MessageStore } from '../types/message-store.js';
import type { PermissionsGrantMessage } from '../types/permissions-types.js';

Expand All @@ -12,20 +11,18 @@ export class GrantAuthorization {
/**
* Performs PermissionsGrant-based authorization against the given message
* Does not validate grant `conditions` or `scope` beyond `interface` and `method`
* @throws {Error} if authorization fails
* @throws {DwnError} if authorization fails
*/
public static async authorizeGenericMessage(
tenant: string,
incomingMessage: MessageInterface<GenericMessage>,
incomingMessage: GenericMessage,
author: string,
permissionsGrantId: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<PermissionsGrantMessage> {

const incomingMessageDescriptor = incomingMessage.message.descriptor;
): Promise<void> {

// Fetch grant
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, permissionsGrantId);
const incomingMessageDescriptor = incomingMessage.descriptor;
const permissionsGrantId = await Message.getCid(permissionsGrantMessage);

GrantAuthorization.verifyGrantedToAndGrantedFor(author, tenant, permissionsGrantMessage);

Expand All @@ -45,16 +42,14 @@ export class GrantAuthorization {
permissionsGrantMessage,
permissionsGrantId
);

return permissionsGrantMessage;
}

/**
* Fetches PermissionsGrantMessage with CID `permissionsGrantId`.
* @returns the PermissionsGrantMessage with CID `permissionsGrantId` if message exists
* @throws {Error} if PermissionsGrantMessage with CID `permissionsGrantId` does not exist
*/
private static async fetchGrant(
public static async fetchGrant(
tenant: string,
messageStore: MessageStore,
permissionsGrantId: string,
Expand Down Expand Up @@ -101,7 +96,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`.
* @throws {Error} if incomingMessage has timestamp for a time in which the grant is not active.
* @throws {DwnError} if incomingMessage has timestamp for a time in which the grant is not active.
*/
private static async verifyGrantActive(
tenant: string,
Expand Down Expand Up @@ -144,7 +139,8 @@ export class GrantAuthorization {

/**
* Verify that the `interface` and `method` grant scopes match the incoming message
* @throws {Error} if the `interface` and `method` of the incoming message do not match the scope of the PermissionsGrant
* @param permissionsGrantId Purely being passed for logging purposes.
* @throws {DwnError} if the `interface` and `method` of the incoming message do not match the scope of the PermissionsGrant
*/
private static async verifyGrantScopeInterfaceAndMethod(
dwnInterface: string,
Expand Down
46 changes: 24 additions & 22 deletions src/core/records-grant-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,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 { RecordsWrite } from '../interfaces/records-write.js';
import type { RecordsWriteMessage } from '../types/records-types.js';

import { GrantAuthorization } from './grant-authorization.js';
import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js';
Expand All @@ -14,15 +14,16 @@ export class RecordsGrantAuthorization {
*/
public static async authorizeWrite(
tenant: string,
incomingMessage: RecordsWrite,
incomingMessage: RecordsWriteMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.authorizeGenericMessage(
tenant,
incomingMessage,
author,
incomingMessage.signaturePayload!.permissionsGrantId!,
permissionsGrantMessage,
messageStore
);

Expand All @@ -37,47 +38,48 @@ export class RecordsGrantAuthorization {
public static async authorizeRead(
tenant: string,
incomingMessage: RecordsRead,
newestRecordsWrite: RecordsWrite,
newestRecordsWriteMessage: RecordsWriteMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.authorizeGenericMessage(
tenant,
incomingMessage,
incomingMessage.message,
author,
incomingMessage.signaturePayload!.permissionsGrantId!,
permissionsGrantMessage,
messageStore
);

RecordsGrantAuthorization.verifyScope(newestRecordsWrite, permissionsGrantMessage);
RecordsGrantAuthorization.verifyScope(newestRecordsWriteMessage, permissionsGrantMessage);
}

/**
* @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.
*/
private static verifyScope(
recordsWrite: RecordsWrite,
recordsWriteMessage: RecordsWriteMessage,
permissionsGrantMessage: PermissionsGrantMessage,
): void {
const grantScope = permissionsGrantMessage.descriptor.scope as RecordsPermissionScope;

if (RecordsGrantAuthorization.isUnrestrictedScope(grantScope)) {
// scope has no restrictions beyond interface and method. Message is authorized to access any record.
return;
} else if (recordsWrite.message.descriptor.protocol !== undefined) {
} else if (recordsWriteMessage.descriptor.protocol !== undefined) {
// authorization of protocol records must have grants that explicitly include the protocol
RecordsGrantAuthorization.authorizeProtocolRecord(recordsWrite, grantScope);
RecordsGrantAuthorization.authorizeProtocolRecord(recordsWriteMessage, grantScope);
} else {
RecordsGrantAuthorization.authorizeFlatRecord(recordsWrite, grantScope);
RecordsGrantAuthorization.authorizeFlatRecord(recordsWriteMessage, grantScope);
}
}

/**
* Authorizes a grant scope for a protocol record
*/
private static authorizeProtocolRecord(
recordsWrite: RecordsWrite,
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope
): void {
// Protocol records must have grants specifying the protocol
Expand All @@ -89,23 +91,23 @@ export class RecordsGrantAuthorization {
}

// The record's protocol must match the protocol specified in the record
if (grantScope.protocol !== recordsWrite.message.descriptor.protocol) {
if (grantScope.protocol !== recordsWriteMessage.descriptor.protocol) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch,
`Grant scope specifies different protocol than what appears in the record`
);
}

// If grant specifies either contextId, check that record is that context
if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWrite.message.contextId) {
if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWriteMessage.contextId) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeContextIdMismatch,
`Grant scope specifies different contextId than what appears in the record`
);
}

// If grant specifies protocolPath, check that record is at that protocolPath
if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWrite.message.descriptor.protocolPath) {
if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWriteMessage.descriptor.protocolPath) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeProtocolPathMismatch,
`Grant scope specifies different protocolPath than what appears in the record`
Expand All @@ -117,11 +119,11 @@ export class RecordsGrantAuthorization {
* Authorizes a grant scope for a non-protocol record
*/
private static authorizeFlatRecord(
recordsWrite: RecordsWrite,
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope
): void {
if (grantScope.schema !== undefined) {
if (grantScope.schema !== recordsWrite.message.descriptor.schema) {
if (grantScope.schema !== recordsWriteMessage.descriptor.schema) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeSchema,
`Record does not have schema in PermissionsGrant scope with schema '${grantScope.schema}'`
Expand All @@ -134,19 +136,19 @@ export class RecordsGrantAuthorization {
* Verifies grant `conditions`.
* Currently the only condition is `published` which only applies to RecordsWrites
*/
private static verifyConditions(incomingMessage: RecordsWrite, permissionsGrantMessage: PermissionsGrantMessage): void {
private static verifyConditions(recordsWriteMessage: RecordsWriteMessage, permissionsGrantMessage: PermissionsGrantMessage): void {
const conditions = permissionsGrantMessage.descriptor.conditions;

// If conditions require publication, RecordsWrite must have `published` === true
if (conditions?.publication === PermissionsConditionPublication.Required && !incomingMessage.message.descriptor.published) {
if (conditions?.publication === PermissionsConditionPublication.Required && !recordsWriteMessage.descriptor.published) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationConditionPublicationRequired,
'PermissionsGrant requires message to be published'
);
}

// if conditions prohibit publication, RecordsWrite must have published === false or undefined
if (conditions?.publication === PermissionsConditionPublication.Prohibited && incomingMessage.message.descriptor.published) {
if (conditions?.publication === PermissionsConditionPublication.Prohibited && recordsWriteMessage.descriptor.published) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationConditionPublicationProhibited,
'PermissionsGrant prohibits message from being published'
Expand Down
6 changes: 5 additions & 1 deletion src/handlers/records-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { authenticate } from '../core/auth.js';
import { DataStream } from '../utils/data-stream.js';
import { DwnInterfaceName } from '../enums/dwn-interface-method.js';
import { Encoder } from '../utils/encoder.js';
import { GrantAuthorization } from '../core/grant-authorization.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
Expand Down Expand Up @@ -113,7 +114,10 @@ export class RecordsReadHandler implements MethodHandler {
// The recipient of a message may always read it
return;
} else if (recordsRead.author !== undefined && recordsRead.signaturePayload!.permissionsGrantId !== undefined) {
await RecordsGrantAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, recordsRead.author, messageStore);
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsRead.signaturePayload!.permissionsGrantId);
await RecordsGrantAuthorization.authorizeRead(
tenant, recordsRead, newestRecordsWrite.message, recordsRead.author, permissionsGrantMessage, messageStore
);
} else if (descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, messageStore);
} else {
Expand Down
8 changes: 7 additions & 1 deletion src/handlers/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Cid } from '../utils/cid.js';
import { DataStream } from '../utils/data-stream.js';
import { DwnConstant } from '../core/dwn-constant.js';
import { Encoder } from '../utils/encoder.js';
import { GrantAuthorization } from '../core/grant-authorization.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
Expand Down Expand Up @@ -260,6 +261,10 @@ export class RecordsWriteHandler implements MethodHandler {
);
}

if (recordsWrite.isSignedByDelegatee) {
await recordsWrite.authorizeDelegatee(messageStore);
}

if (recordsWrite.owner !== undefined) {
// if incoming message is a write retained by this tenant, we by-design always allow
// NOTE: the "owner === tenant" check is already done earlier in this method
Expand All @@ -268,7 +273,8 @@ export class RecordsWriteHandler implements MethodHandler {
// if author is the same as the target tenant, we can directly grant access
return;
} else if (recordsWrite.author !== undefined && recordsWrite.signaturePayload!.permissionsGrantId !== undefined) {
await RecordsGrantAuthorization.authorizeWrite(tenant, recordsWrite, recordsWrite.author, messageStore);
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsWrite.signaturePayload!.permissionsGrantId);
await RecordsGrantAuthorization.authorizeWrite(tenant, recordsWrite.message, recordsWrite.author, permissionsGrantMessage, messageStore);
} else if (recordsWrite.message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeWrite(tenant, recordsWrite, messageStore);
} else {
Expand Down
5 changes: 3 additions & 2 deletions src/interfaces/protocols-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ export class ProtocolsQuery extends AbstractMessage<ProtocolsQueryMessage> {
if (this.author === tenant) {
return;
} else if (this.author !== undefined && this.signaturePayload!.permissionsGrantId) {
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, this.signaturePayload!.permissionsGrantId);
await GrantAuthorization.authorizeGenericMessage(
tenant,
this,
this.message,
this.author,
this.signaturePayload!.permissionsGrantId,
permissionsGrantMessage,
messageStore
);
} else {
Expand Down
23 changes: 23 additions & 0 deletions src/interfaces/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Jws } from '../utils/jws.js';
import { KeyDerivationScheme } from '../utils/hd-key.js';
import { Message } from '../core/message.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Secp256k1 } from '../utils/secp256k1.js';
import { Time } from '../utils/time.js';
Expand Down Expand Up @@ -182,6 +183,21 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
return this._ownerSignaturePayload;
}

/**
* If this message is signed by a delegated entity.
*/
public get isSignedByDelegatee(): boolean {
return this._message.authorization?.authorDelegatedGrant !== undefined;
}

/**
* Gets the signer of this message.
* This is not to be confused with the logical author of the message.
*/
public get signer(): string | undefined {
return Message.getSigner(this._message);
}

readonly attesters: string[];

private constructor(message: InternalRecordsWriteMessage) {
Expand Down Expand Up @@ -696,6 +712,13 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
return indexes;
}

public async authorizeDelegatee(messageStore: MessageStore): Promise<void> {
const grantedTo = this.signer!;
const grantedFor = this.author!;
const delegatedGrant = this.message.authorization.authorDelegatedGrant!;
await RecordsGrantAuthorization.authorizeWrite(grantedFor, this.message, grantedTo, delegatedGrant, messageStore);
}


/**
* Checks if the given message is the initial entry of a record.
Expand Down
3 changes: 2 additions & 1 deletion src/utils/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ export class Records {

// when delegated grant exists, the grantee (grantedTo) must be the same as the signer of the message
if (authorDelegatedGrantDefined) {
const grantedTo = message.authorization!.authorDelegatedGrant!.descriptor.grantedTo;
const delegatedGrant = message.authorization!.authorDelegatedGrant!;
const grantedTo = delegatedGrant.descriptor.grantedTo;
const signer = Message.getSigner(message);
if (grantedTo !== signer) {
throw new DwnError(
Expand Down
15 changes: 15 additions & 0 deletions tests/interfaces/records-write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,21 @@ describe('RecordsWrite', () => {
});
});

describe('isSignedByDelegatee()', () => {
it('should return false if the given RecordsWrite is not signed at all', async () => {
const data = new TextEncoder().encode('any data');
const recordsWrite = await RecordsWrite.create({
protocol : 'unused',
protocolPath : 'unused',
schema : 'unused',
dataFormat : 'unused',
data
});

const isSignedByDelegatee = recordsWrite.isSignedByDelegatee;
expect(isSignedByDelegatee).to.be.false;
});
});

describe('isInitialWrite()', () => {
it('should return false if given message is not a RecordsWrite', async () => {
Expand Down
Loading

0 comments on commit 066e74b

Please sign in to comment.