diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 7f4ea41d0..7b9d4912c 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -53,6 +53,7 @@ export enum DwnErrorCode { PermissionsProtocolValidateScopeContextIdProhibitedProperties = 'PermissionsProtocolValidateScopeContextIdProhibitedProperties', PermissionsProtocolValidateScopeProtocolMismatch = 'PermissionsProtocolValidateScopeProtocolMismatch', PermissionsProtocolValidateScopeMissingProtocolTag = 'PermissionsProtocolValidateScopeMissingProtocolTag', + PermissionsProtocolValidateRevocationProtocolTagMismatch = 'PermissionsProtocolValidateRevocationProtocolTagMismatch', PrivateKeySignerUnableToDeduceAlgorithm = 'PrivateKeySignerUnableToDeduceAlgorithm', PrivateKeySignerUnableToDeduceKeyId = 'PrivateKeySignerUnableToDeduceKeyId', PrivateKeySignerUnsupportedCurve = 'PrivateKeySignerUnsupportedCurve', diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 6165424a2..df3e7b4cd 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -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` @@ -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 { + 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); diff --git a/src/protocols/permissions.ts b/src/protocols/permissions.ts index 797615dda..6de8e69a9 100644 --- a/src/protocols/permissions.ts +++ b/src/protocols/permissions.ts @@ -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 @@ -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, @@ -287,6 +297,7 @@ export class PermissionsProtocol { protocolPath : PermissionsProtocol.revocationPath, dataFormat : 'application/json', data : permissionRevocationBytes, + tags : permissionTags, }); return { @@ -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'; } diff --git a/tests/features/author-delegated-grant.spec.ts b/tests/features/author-delegated-grant.spec.ts index 20575e330..c15f47824 100644 --- a/tests/features/author-delegated-grant.spec.ts +++ b/tests/features/author-delegated-grant.spec.ts @@ -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 }); diff --git a/tests/features/owner-delegated-grant.spec.ts b/tests/features/owner-delegated-grant.spec.ts index 10bcc5729..cc27341df 100644 --- a/tests/features/owner-delegated-grant.spec.ts +++ b/tests/features/owner-delegated-grant.spec.ts @@ -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( diff --git a/tests/features/permissions.spec.ts b/tests/features/permissions.spec.ts index 385436bfd..38a0a2295 100644 --- a/tests/features/permissions.spec.ts +++ b/tests/features/permissions.spec.ts @@ -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 @@ -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({ @@ -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( @@ -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( @@ -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`