Skip to content

Commit

Permalink
revocation must include a protocol if grant is scoped to one
Browse files Browse the repository at this point in the history
  • Loading branch information
LiranCohen committed Jun 13, 2024
1 parent d51bb62 commit f678d9f
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 8 deletions.
1 change: 1 addition & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export enum DwnErrorCode {
PermissionsProtocolValidateScopeContextIdProhibitedProperties = 'PermissionsProtocolValidateScopeContextIdProhibitedProperties',
PermissionsProtocolValidateScopeProtocolMismatch = 'PermissionsProtocolValidateScopeProtocolMismatch',
PermissionsProtocolValidateScopeMissingProtocolTag = 'PermissionsProtocolValidateScopeMissingProtocolTag',
PermissionsProtocolValidateRevocationProtocolTagMismatch = 'PermissionsProtocolValidateRevocationProtocolTagMismatch',
PrivateKeySignerUnableToDeduceAlgorithm = 'PrivateKeySignerUnableToDeduceAlgorithm',
PrivateKeySignerUnableToDeduceKeyId = 'PrivateKeySignerUnableToDeduceKeyId',
PrivateKeySignerUnsupportedCurve = 'PrivateKeySignerUnsupportedCurve',
Expand Down
22 changes: 22 additions & 0 deletions src/handlers/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export class RecordsWriteHandler implements MethodHandler {
}

try {
await this.validateUserlandRulesForCoreRecordsWrite(tenant, message);

// NOTE: We allow isLatestBaseState to be true ONLY if the incoming message comes with data, or if the incoming message is NOT an initial write
// This would allow an initial write to be written to the DB without data, but having it not queryable,
// because query implementation filters on `isLatestBaseState` being `true`
Expand Down Expand Up @@ -170,6 +172,26 @@ export class RecordsWriteHandler implements MethodHandler {
return messageReply;
};

/**
* Performs additional necessary validation if the RecordsWrite handled is a core DWN RecordsWrite that need additional processing.
* For instance: a Permission revocation RecordsWrite.
*/
private async validateUserlandRulesForCoreRecordsWrite(tenant: string, recordsWriteMessage: RecordsWriteMessage): Promise<void> {
if (recordsWriteMessage.descriptor.protocol === PermissionsProtocol.uri &&
recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.revocationPath) {
const permissionGrantId = recordsWriteMessage.descriptor.parentId!;
const grant = await PermissionsProtocol.fetchGrant(tenant, this.messageStore, permissionGrantId);
const revokeTagProtocol = recordsWriteMessage.descriptor.tags?.protocol;
const grantProtocol = PermissionsProtocol.isRecordPermissionScope(grant.scope) ? grant.scope.protocol : undefined;
if (grantProtocol !== revokeTagProtocol) {
throw new DwnError(
DwnErrorCode.PermissionsProtocolValidateRevocationProtocolTagMismatch,
`Revocation protocol ${revokeTagProtocol} does not match grant protocol ${grantProtocol}`
);
}
}
}

private static validateSchemaForCoreRecordsWrite(recordsWriteMessage: RecordsWriteMessage, dataBytes: Uint8Array): void {
if (recordsWriteMessage.descriptor.protocol === PermissionsProtocol.uri) {
PermissionsProtocol.validateSchema(recordsWriteMessage, dataBytes);
Expand Down
13 changes: 12 additions & 1 deletion src/protocols/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export type PermissionRevocationCreateOptions = {
*/
signer?: Signer;
grantId: string;
/**
* If the grant was scoped toa protocol, the protocol must be included in the revocation.
*/
protocol?: string;
dateRevoked?: string;

// remaining properties are contained within the data payload of the record
Expand Down Expand Up @@ -279,6 +283,12 @@ export class PermissionsProtocol {
description: options.description,
};

let permissionTags = undefined;
if (options.protocol !== undefined) {
const protocol = normalizeProtocolUrl(options.protocol);
permissionTags = { protocol };
}

const permissionRevocationBytes = Encoder.objectToBytes(permissionRevocationData);
const recordsWrite = await RecordsWrite.create({
signer : options.signer,
Expand All @@ -287,6 +297,7 @@ export class PermissionsProtocol {
protocolPath : PermissionsProtocol.revocationPath,
dataFormat : 'application/json',
data : permissionRevocationBytes,
tags : permissionTags,
});

return {
Expand Down Expand Up @@ -379,7 +390,7 @@ export class PermissionsProtocol {
/**
* Type guard to determine if the scope is a record permission scope.
*/
private static isRecordPermissionScope(scope: PermissionScope): scope is RecordsPermissionScope {
public static isRecordPermissionScope(scope: PermissionScope): scope is RecordsPermissionScope {
return scope.interface === 'Records';
}

Expand Down
5 changes: 3 additions & 2 deletions tests/features/author-delegated-grant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,8 +1189,9 @@ export function testAuthorDelegatedGrant(): void {

// 3. Alice revokes the grant
const permissionRevoke = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : deviceXGrant.recordsWrite.message.recordId
signer : Jws.createSigner(alice),
grantId : deviceXGrant.recordsWrite.message.recordId,
protocol : scope.protocol,
});
const revocationDataStream = DataStream.fromBytes(permissionRevoke.permissionRevocationBytes);
const permissionRevokeReply = await dwn.processMessage(alice.did, permissionRevoke.recordsWrite.message, { dataStream: revocationDataStream });
Expand Down
5 changes: 3 additions & 2 deletions tests/features/owner-delegated-grant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,8 +601,9 @@ export function testOwnerDelegatedGrant(): void {

// 3. Alice revokes the grant
const permissionRevoke = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : appXGrant.recordsWrite.message.recordId
signer : Jws.createSigner(alice),
grantId : appXGrant.recordsWrite.message.recordId,
protocol : scope.protocol,
});
const revocationDataStream = DataStream.fromBytes(permissionRevoke.permissionRevocationBytes);
const permissionRevokeReply = await dwn.processMessage(
Expand Down
196 changes: 193 additions & 3 deletions tests/features/permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,123 @@ export function testPermissions(): void {
await dwn.close();
});

it('should include record tags using the createRequest, createGrant and createRevocation if provided', async () => {
const alice = await TestDataGenerator.generateDidKeyPersona();

// createRequest with a protocol
const requestWrite = await PermissionsProtocol.createRequest({
signer : Jws.createSigner(alice),
description : 'Requesting to write',
delegated : false,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'https://example.com/protocol/test'
}
});
expect(requestWrite.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'https://example.com/protocol/test' });

// createGrant with a protocol
const grantWrite = await PermissionsProtocol.createGrant({
signer : Jws.createSigner(alice),
dateExpires : Time.createOffsetTimestamp({ seconds: 100 }),
description : 'Allow Bob to write',
grantedTo : alice.did,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'https://example.com/protocol/test'
}
});
expect(grantWrite.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'https://example.com/protocol/test' });

// createRevocation with a protocol
const revokeWrite = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite.recordsWrite.message.recordId,
protocol : 'https://example.com/protocol/test',
dateRevoked : Time.getCurrentTimestamp()
});
expect(revokeWrite.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'https://example.com/protocol/test' });
});

it('should normalize the protocol URL in the scope of a Request, Grant, and Revocation', async () => {
const alice = await TestDataGenerator.generateDidKeyPersona();
const bob = await TestDataGenerator.generateDidKeyPersona();

// createRequest with a protocol that will be normalized to `http://any-protocol`
const requestWrite = await PermissionsProtocol.createRequest({
signer : Jws.createSigner(bob),
description : 'Requesting to write',
delegated : false,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'any-protocol' // URL will normalize to `http://any-protocol`
}
});
expect(requestWrite.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'http://any-protocol' });

// createRequest with a protocol that is already normalized to `https://any-protocol`
const requestWrite2 = await PermissionsProtocol.createRequest({
signer : Jws.createSigner(bob),
description : 'Requesting to write',
delegated : false,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'https://any-protocol'
}
});
expect(requestWrite2.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'https://any-protocol' });

// createGrant with a protocol that will be normalized to `http://any-protocol`
const grantWrite = await PermissionsProtocol.createGrant({
signer : Jws.createSigner(alice),
dateExpires : Time.createOffsetTimestamp({ seconds: 100 }),
description : 'Allow Bob to write',
grantedTo : bob.did,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'any-protocol' // URL will normalize to `http://any-protocol`
}
});
expect(grantWrite.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'http://any-protocol' });

// createGrant with a protocol that is already normalized to `https://any-protocol`
const grantWrite2 = await PermissionsProtocol.createGrant({
signer : Jws.createSigner(alice),
dateExpires : Time.createOffsetTimestamp({ seconds: 100 }),
description : 'Allow Bob to write',
grantedTo : bob.did,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : 'https://any-protocol'
}
});
expect(grantWrite2.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'https://any-protocol' });

// createRevocation with a protocol that will be normalized to `http://any-protocol`
const revokeWrite = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite.recordsWrite.message.recordId,
protocol : 'any-protocol', // URL will normalize to `http://any-protocol`
dateRevoked : Time.getCurrentTimestamp()
});
expect(revokeWrite.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'http://any-protocol' });

// createRevocation with a protocol that is already normalized to `https://any-protocol`
const revokeWrite2 = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite2.recordsWrite.message.recordId,
protocol : 'https://any-protocol',
dateRevoked : Time.getCurrentTimestamp()
});
expect(revokeWrite2.recordsWrite.message.descriptor.tags).to.deep.equal({ protocol: 'https://any-protocol' });
});

it('should support permission management through use of Request, Grants, and Revocations', async () => {
// scenario:
// 1. Verify anyone (Bob) can send a permission request to Alice
Expand All @@ -79,7 +196,7 @@ export function testPermissions(): void {
const permissionScope: PermissionScope = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : `any-protocol`
protocol : `any-protocol` // URL will normalize to `http://any-protocol`
};

const requestToAlice = await PermissionsProtocol.createRequest({
Expand Down Expand Up @@ -178,7 +295,8 @@ export function testPermissions(): void {
const unauthorizedRevokeWrite = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(bob),
grantId : grantWrite.recordsWrite.message.recordId,
dateRevoked : Time.getCurrentTimestamp()
dateRevoked : Time.getCurrentTimestamp(),
protocol : permissionScope.protocol
});

const unauthorizedRevokeWriteReply = await dwn.processMessage(
Expand All @@ -193,7 +311,8 @@ export function testPermissions(): void {
const revokeWrite = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite.recordsWrite.message.recordId,
dateRevoked : Time.getCurrentTimestamp()
dateRevoked : Time.getCurrentTimestamp(),
protocol : permissionScope.protocol
});

const revokeWriteReply = await dwn.processMessage(
Expand Down Expand Up @@ -256,6 +375,77 @@ export function testPermissions(): void {
}
});

it('performs additional validation to tagged protocol in a Revocation message matches the Grant it is revoking', async () => {
const alice = await TestDataGenerator.generateDidKeyPersona();
const bob = await TestDataGenerator.generateDidKeyPersona();

const grantProtocol = 'https://example.com/protocol/test';
const invalidProtocol = 'https://example.com/protocol/invalid';

// alice creates a grant for bob
const grantWrite = await PermissionsProtocol.createGrant({
signer : Jws.createSigner(alice),
dateExpires : Time.createOffsetTimestamp({ seconds: 100 }),
description : 'Allow Bob to write',
grantedTo : bob.did,
scope : {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : grantProtocol,
}
});
const grantWriteReply = await dwn.processMessage(alice.did, grantWrite.recordsWrite.message, {
dataStream: DataStream.fromBytes(grantWrite.permissionGrantBytes)
});
expect(grantWriteReply.status.code).to.equal(202);

// revoke the grant without a protocol set
const revokeWriteWithoutProtocol = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite.recordsWrite.message.recordId,
dateRevoked : Time.getCurrentTimestamp()
});

const revokeWriteWithoutProtocolReply = await dwn.processMessage(alice.did, revokeWriteWithoutProtocol.recordsWrite.message, {
dataStream: DataStream.fromBytes(revokeWriteWithoutProtocol.permissionRevocationBytes)
});
expect(revokeWriteWithoutProtocolReply.status.code).to.equal(400);
expect(revokeWriteWithoutProtocolReply.status.detail).to.contain(DwnErrorCode.PermissionsProtocolValidateRevocationProtocolTagMismatch);
expect(revokeWriteWithoutProtocolReply.status.detail).to.contain(
`Revocation protocol undefined does not match grant protocol ${grantProtocol}`
);

// revoke the grant with an invalid protocol
const revokeWriteWithMissMatchedProtocol = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite.recordsWrite.message.recordId,
protocol : invalidProtocol,
dateRevoked : Time.getCurrentTimestamp()
});

const revokeWriteWithMissMatchedProtocolReply = await dwn.processMessage(alice.did, revokeWriteWithMissMatchedProtocol.recordsWrite.message, {
dataStream: DataStream.fromBytes(revokeWriteWithMissMatchedProtocol.permissionRevocationBytes)
});
expect(revokeWriteWithMissMatchedProtocolReply.status.code).to.equal(400);
expect(revokeWriteWithMissMatchedProtocolReply.status.detail).to.contain(DwnErrorCode.PermissionsProtocolValidateRevocationProtocolTagMismatch);
expect(revokeWriteWithMissMatchedProtocolReply.status.detail).to.contain(
`Revocation protocol ${invalidProtocol} does not match grant protocol ${grantProtocol}`
);

// revoke the grant with a valid protocol
const revokeWrite = await PermissionsProtocol.createRevocation({
signer : Jws.createSigner(alice),
grantId : grantWrite.recordsWrite.message.recordId,
protocol : grantProtocol,
dateRevoked : Time.getCurrentTimestamp()
});

const revokeWriteReply = await dwn.processMessage(alice.did, revokeWrite.recordsWrite.message, {
dataStream: DataStream.fromBytes(revokeWrite.permissionRevocationBytes)
});
expect(revokeWriteReply.status.code).to.equal(202);
});

describe('validateScope', async () => {
it('should be called for a Request or Grant record', async () => {
// spy on `validateScope`
Expand Down

0 comments on commit f678d9f

Please sign in to comment.