Skip to content

Commit

Permalink
#564 - Added scoping support for delegated grants
Browse files Browse the repository at this point in the history
  • Loading branch information
thehenrytsai committed Nov 18, 2023
1 parent 6c01ea2 commit a62a5db
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 21 deletions.
46 changes: 35 additions & 11 deletions src/core/grant-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,46 @@ 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(
public static async fetchPermissionsGrantAndAuthorizeGenericMessage(
tenant: string,
incomingMessage: MessageInterface<GenericMessage>,
author: string,
permissionsGrantId: string,
messageStore: MessageStore,
): Promise<PermissionsGrantMessage> {

const incomingMessageDescriptor = incomingMessage.message.descriptor;

// Fetch grant
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, permissionsGrantId);

await GrantAuthorization.authorizeGenericMessage(
tenant,
incomingMessage,
author, // TODO: rename to `grantedTo` or grantee?!

Check failure on line 31 in src/core/grant-authorization.ts

View workflow job for this annotation

GitHub Actions / test-with-node (20.3.0)

TODO comment doesn't reference a ticket number. Comment pattern: .*github.com/TBD54566975/dwn-sdk-js/issues/.*
permissionsGrantMessage,
messageStore
);

return permissionsGrantMessage;
}

/**
* Performs PermissionsGrant-based authorization against the given message
* Does not validate grant `conditions` or `scope` beyond `interface` and `method`
* @throws {DwnError} if authorization fails
*/
public static async authorizeGenericMessage(
tenant: string,
incomingMessage: MessageInterface<GenericMessage>,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {

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

GrantAuthorization.verifyGrantedToAndGrantedFor(author, tenant, permissionsGrantMessage);

// verify that grant is active during incomingMessage's timestamp
Expand All @@ -45,8 +70,6 @@ export class GrantAuthorization {
permissionsGrantMessage,
permissionsGrantId
);

return permissionsGrantMessage;
}

/**
Expand Down Expand Up @@ -77,7 +100,7 @@ export class GrantAuthorization {
/**
* Verifies the given `grantedTo` and `grantedFor` values against the given permissions grant and throws error if there is a mismatch.
*/
private static verifyGrantedToAndGrantedFor(grantedTo: string, grantedFor: string, permissionsGrantMessage: PermissionsGrantMessage): void {
public static verifyGrantedToAndGrantedFor(grantedTo: string, grantedFor: string, permissionsGrantMessage: PermissionsGrantMessage): void {
// Validate `grantedTo`
const expectedGrantedTo = permissionsGrantMessage.descriptor.grantedTo;
if (expectedGrantedTo !== grantedTo) {
Expand All @@ -101,9 +124,9 @@ 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(
public static async verifyGrantActive(
tenant: string,
incomingMessageTimestamp: string,
permissionsGrantMessage: PermissionsGrantMessage,
Expand Down Expand Up @@ -144,9 +167,10 @@ 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(
public static async verifyGrantScopeInterfaceAndMethod(
dwnInterface: string,
dwnMethod: string,
permissionsGrantMessage: PermissionsGrantMessage,
Expand Down
8 changes: 4 additions & 4 deletions src/core/records-grant-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class RecordsGrantAuthorization {
author: string,
messageStore: MessageStore,
): Promise<void> {
const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage(
const permissionsGrantMessage = await GrantAuthorization.fetchPermissionsGrantAndAuthorizeGenericMessage(
tenant,
incomingMessage,
author,
Expand All @@ -41,7 +41,7 @@ export class RecordsGrantAuthorization {
author: string,
messageStore: MessageStore,
): Promise<void> {
const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage(
const permissionsGrantMessage = await GrantAuthorization.fetchPermissionsGrantAndAuthorizeGenericMessage(
tenant,
incomingMessage,
author,
Expand All @@ -56,7 +56,7 @@ export class RecordsGrantAuthorization {
* @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(
public static verifyScope(
recordsWrite: RecordsWrite,
permissionsGrantMessage: PermissionsGrantMessage,
): void {
Expand Down Expand Up @@ -134,7 +134,7 @@ 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 {
public static verifyConditions(incomingMessage: RecordsWrite, permissionsGrantMessage: PermissionsGrantMessage): void {
const conditions = permissionsGrantMessage.descriptor.conditions;

// If conditions require publication, RecordsWrite must have `published` === true
Expand Down
33 changes: 33 additions & 0 deletions src/handlers/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RecordsWrite } from '../interfaces/records-write.js';
import { StorageController } from '../store/storage-controller.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { GrantAuthorization } from '../core/grant-authorization.js';

Check failure on line 22 in src/handlers/records-write.ts

View workflow job for this annotation

GitHub Actions / test-with-node (20.3.0)

Expected 'single' syntax before 'multiple' syntax

export type RecordsWriteHandlerOptions = {
skipDataStorage?: boolean; // used for DWN sync
Expand Down Expand Up @@ -260,6 +261,38 @@ export class RecordsWriteHandler implements MethodHandler {
);
}

if (recordsWrite.isSignedByDelegatee) {
// NEED TO consolidate RecordsGrantAuthorization.authorizeWrite, authorizeGenericMessage()

const grantedTo = recordsWrite.signer!;
const grantedFor = recordsWrite.author!;
const delegatedGrant = recordsWrite.message.authorization.authorDelegatedGrant!;
GrantAuthorization.verifyGrantedToAndGrantedFor(grantedTo, grantedFor, delegatedGrant);

// verify that grant is active during incomingMessage's timestamp
const incomingMessageDescriptor = recordsWrite.message.descriptor;
const delegatedGrantId = await Message.getCid(delegatedGrant);
await GrantAuthorization.verifyGrantActive(
tenant,
incomingMessageDescriptor.messageTimestamp,
delegatedGrant,
delegatedGrantId,
messageStore
);

// Check grant scope for interface and method
await GrantAuthorization.verifyGrantScopeInterfaceAndMethod(
incomingMessageDescriptor.interface,
incomingMessageDescriptor.method,
delegatedGrant,
delegatedGrantId
);

RecordsGrantAuthorization.verifyScope(recordsWrite, delegatedGrant);

RecordsGrantAuthorization.verifyConditions(recordsWrite, delegatedGrant);
}

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 Down
2 changes: 1 addition & 1 deletion src/interfaces/protocols-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class ProtocolsQuery extends AbstractMessage<ProtocolsQueryMessage> {
if (this.author === tenant) {
return;
} else if (this.author !== undefined && this.signaturePayload!.permissionsGrantId) {
await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.fetchPermissionsGrantAndAuthorizeGenericMessage(
tenant,
this,
this.author,
Expand Down
15 changes: 15 additions & 0 deletions src/interfaces/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,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
4 changes: 3 additions & 1 deletion src/utils/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Message } from '../core/message.js';
import { Secp256k1 } from './secp256k1.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { normalizeProtocolUrl, normalizeSchemaUrl } from './url.js';
import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js';

Check failure on line 13 in src/utils/records.ts

View workflow job for this annotation

GitHub Actions / test-with-node (20.3.0)

Expected 'single' syntax before 'multiple' syntax

Check failure on line 13 in src/utils/records.ts

View workflow job for this annotation

GitHub Actions / test-with-node (20.3.0)

'RecordsPermissionScope' is defined but never used. Allowed unused vars must match /^_/u

/**
* Class containing useful utilities related to the Records interface.
Expand Down Expand Up @@ -312,7 +313,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
61 changes: 57 additions & 4 deletions tests/scenarios/delegated-grant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function testDelegatedGrantScenarios(): void {
await dwn.close();
});

it('should only allow entity invoking a valid delegated grant to write', async () => {
it('should only allow correct entity invoking a delegated grant to write', async () => {
// scenario:
// 1. Alice creates a delegated grant for Device X and Device Y,
// 2. Device X and Y can both use their grants to write a message to Bob's DWN as Alice
Expand Down Expand Up @@ -180,7 +180,7 @@ export function testDelegatedGrantScenarios(): void {
expect(carolWriteReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch);
});

it('should only allow entity invoking a valid delegated grant to read or query', async () => {
it('should only allow correct entity invoking a delegated grant to read or query', async () => {
// scenario:
// 1. Alice creates a delegated grant for device X,
// 2. Bob starts a chat thread with Alice on his DWN
Expand Down Expand Up @@ -332,7 +332,7 @@ export function testDelegatedGrantScenarios(): void {
expect(recordsQueryByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch);
});

it('should only allow entity invoking a valid delegated grant to delete', async () => {
it('should only allow entity invoking a delegated grant to delete', async () => {
// scenario:
// 1. Bob installs the chat protocol on his DWN and makes Alice an admin
// 2. Bob starts a chat thread with Carol on his DWN
Expand Down Expand Up @@ -464,7 +464,60 @@ export function testDelegatedGrantScenarios(): void {
xit('should not allow entity using a non-delegated grant as a delegated grant to invoke delete', async () => {
});

xit('should evaluate scoping correctly when invoking a delegated grant to write', async () => {
it('should fail if delegated grant has a mismatching protocol scope - write', async () => {
// scenario:
// 1. Alice creates a delegated grant for device X to act as her for a protocol that is NOT email protocol
// 2. Bob has email protocol configured for his DWN
// 3. Device X attempts to use the delegated grant to write an email to Bob as Alice
// 4. Bob's DWN should reject Device X's message
const alice = await DidKeyResolver.generate();
const deviceX = await DidKeyResolver.generate();
const bob = await DidKeyResolver.generate();

// 1. Alice creates a delegated grant for device X to act as her for a protocol that is NOT email protocol
const scope: PermissionScope = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'random-protocol'
};

const deviceXGrant = await PermissionsGrant.create({
delegated : true, // this is a delegated grant
dateExpires : Time.createOffsetTimestamp({ seconds: 100 }),
description : 'Allow to write to some random protocol',
grantedBy : alice.did,
grantedTo : deviceX.did,
grantedFor : alice.did,
scope : scope,
signer : Jws.createSigner(alice)
});

// 2. Bob has email protocol configured for his DWN
const protocolDefinition = emailProtocolDefinition;
const protocol = protocolDefinition.protocol;
const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({
author: bob,
protocolDefinition
});
const protocolConfigureReply = await dwn.processMessage(bob.did, protocolsConfig.message);
expect(protocolConfigureReply.status.code).to.equal(202);

// 3. Device X attempts to use the delegated grant to write an email to Bob as Alice
const deviceXData = new TextEncoder().encode('message from device X');
const deviceXDataStream = DataStream.fromBytes(deviceXData);
const messageByDeviceX = await RecordsWrite.create({
signer : Jws.createSigner(deviceX),
delegatedGrant : deviceXGrant.asDelegatedGrant(),
protocol,
protocolPath : 'email', // this comes from `types` in protocol definition
schema : protocolDefinition.types.email.schema,
dataFormat : protocolDefinition.types.email.dataFormats[0],
data : deviceXData
});

const deviceXWriteReply = await dwn.processMessage(bob.did, messageByDeviceX.message, deviceXDataStream);
expect(deviceXWriteReply.status.code).to.equal(401);
expect(deviceXWriteReply.status.detail).to.contain(DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch);
});

xit('should evaluate scoping correctly when invoking a delegated grant to read', async () => {
Expand Down

0 comments on commit a62a5db

Please sign in to comment.