diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index eb5e44991..3fde17845 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -12,7 +12,15 @@ import { DwnResponseStatus, ProcessDwnRequest, DwnPaginationCursor, - DwnDataEncodedRecordsWriteMessage + DwnDataEncodedRecordsWriteMessage, + AgentPermissionsApi, + DwnPermissionGrant, + CreateGrantParams, + PermissionGrantEntry, + CreateRevocationParams, + CreateRequestParams, + PermissionRequestEntry, + PermissionRevocationEntry } from '@web5/agent'; import { Convert, isEmptyObject, TtlCache } from '@web5/common'; @@ -234,37 +242,49 @@ export class DwnApi { /** (optional) The DID of the signer when signing with permissions */ private delegateDid?: string; + private permissions: AgentPermissionsApi; + /** cache for fetching permissions */ - private cachedPermissions: TtlCache = new TtlCache({ ttl: 60 * 1000 }); + private cachedPermissions: TtlCache = new TtlCache({ ttl: 60 * 1000 }); constructor(options: { agent: Web5Agent, connectedDid: string, delegateDid?: string }) { this.agent = options.agent; this.connectedDid = options.connectedDid; this.delegateDid = options.delegateDid; + this.permissions = new AgentPermissionsApi({ agent: this.agent }); } /** - * API to interact with grants. + * API to interact with the DWN Permissions when the agent is connected to a delegateDid. * * NOTE: This is an EXPERIMENTAL API that will change behavior. * @beta */ - get grants() { + private get connected() { return { /** * Finds the appropriate permission grants associated with a message request + * + * (optionally) Caches the results for the given parameters to avoid redundant queries. */ - findConnectedPermissionGrant: async ({ messageParams }:{ + findPermissionGrantForRequest: async ({ messageParams, cached = true }:{ + cached?: boolean; messageParams: { - messageType: T, - protocol: string, + messageType: T; + protocol: string; } }) : Promise => { if(!this.delegateDid) { throw new Error('AgentDwnApi: Cannot find connected grants without a signer DID'); } - const permissions = await this.grants.fetchConnectedGrants(); + const cacheKey = [ this.connectedDid, messageParams.messageType, messageParams.protocol ].join('~'); + const cachedGrant = cached ? this.cachedPermissions.get(cacheKey) : undefined; + if (cachedGrant) { + return cachedGrant; + } + + const permissions = await this.connected.fetchConnectedGrants(); // get the delegate grants that match the messageParams and are associated with the connectedDid as the grantor const delegateGrant = await DwnPermissionsUtil.matchGrantFromArray( @@ -279,134 +299,87 @@ export class DwnApi { throw new Error(`AgentDwnApi: No permissions found for ${messageParams.messageType}: ${messageParams.protocol}`); } + this.cachedPermissions.set(cacheKey, delegateGrant.message); return delegateGrant.message; }, /** * Performs a RecordsQuery for permission grants that match the given parameters. - * - * (optionally) Caches the results for the given parameters to avoid redundant queries. */ - fetchConnectedGrants: async (cached: boolean = true): Promise => { + fetchConnectedGrants: async (): Promise => { if (!this.delegateDid) { throw new Error('AgentDwnApi: Cannot fetch grants without a signer DID'); } - const cacheKey = [ this.delegateDid, this.connectedDid ].join('~'); - const cachedGrants = cached ? this.cachedPermissions.get(cacheKey) : undefined; - if (cachedGrants) { - return cachedGrants; - } - - const { reply: grantsReply } = await this.agent.processDwnRequest({ - author : this.delegateDid, - target : this.delegateDid, - messageType : DwnInterface.RecordsQuery, - messageParams : { - filter: { - author : this.connectedDid, // the author of the grant would be the grantor and the logical author of the message - recipient : this.delegateDid, // the recipient of the grant would be the grantee - ...DwnPermissionsUtil.permissionsProtocolParams('grant') - } - } + const fetchResponse = await this.permissions.fetchGrants({ + author : this.delegateDid, + target : this.delegateDid, + grantee : this.delegateDid, + grantor : this.connectedDid, }); - if (grantsReply.status.code !== 200) { - throw new Error(`AgentDwnApi: Failed to fetch grants: ${grantsReply.status.detail}`); - } - const grants:DwnDataEncodedRecordsWriteMessage[] = []; - for (const entry of grantsReply.entries! as DwnDataEncodedRecordsWriteMessage[]) { + for (const entry of fetchResponse) { // check if the grant is revoked, we set the target to the grantor since the grantor is the author of the revocation // the revocations should come in through sync, and are checked against the local DWN - if(await this.grants.isGrantRevoked(this.delegateDid, this.connectedDid, entry.recordId)) { - // grant is revoked do not return it in the grants list + if(await this.permissions.isGrantRevoked(this.delegateDid, this.connectedDid, entry.message.recordId)) { continue; } - grants.push(entry as DwnDataEncodedRecordsWriteMessage); - } - if (cached) { - this.cachedPermissions.set(cacheKey, grants); + grants.push(entry.message); } - return grants; }, + }; + } - /** - * Check whether a grant is revoked by reading the revocation record for a given grant recordId. - */ - isGrantRevoked: async (author:string, target: string, grantRecordId: string): Promise => { - const { reply: revocationReply } = await this.agent.processDwnRequest({ - author, - target, - messageType : DwnInterface.RecordsRead, - messageParams : { - filter: { - parentId: grantRecordId, - ...DwnPermissionsUtil.permissionsProtocolParams('revoke') - } - } + /** + * API to interact with Grants + * + * NOTE: This is an EXPERIMENTAL API that will change behavior. + * @beta + */ + get grants() { + return { + storeGrant: async ({ grant, signAsOwner = false }:{ + grant: DwnDataEncodedRecordsWriteMessage, + signAsOwner?: boolean + }): Promise => { + const signerDid = signAsOwner ? this.delegateDid ?? this.connectedDid : undefined; + const { encodedData, ...rawMessage } = grant; + const { reply, messageCid } = await this.agent.processDwnRequest({ + author : signerDid ?? this.connectedDid, + // if not signing, attempt to store as the connected DID + target : signerDid ?? this.connectedDid, + messageType : DwnInterface.RecordsWrite, + rawMessage, + dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]), + signAsOwner }); - if (revocationReply.status.code === 404) { - // no revocation found, the grant is not revoked - return false; - } else if (revocationReply.status.code === 200) { - // a revocation was found, the grant is revoked - return true; - } - - throw new Error(`AgentDwnApi: Failed to check if grant is revoked: ${revocationReply.status.detail}`); + return { status: reply.status, messageCid }; }, - - /** - * Processes a list of delegated grants as the delegated signer so that they are available for the signer to use. - * - * If any of the grants fail, all the input grants are deleted and an error is thrown. - * Grants cache is cleared after processing. - */ - processConnectedGrantsAsOwner: async (grants: DwnDataEncodedRecordsWriteMessage[]): Promise => { - if(!this.delegateDid) { - throw new Error('AgentDwnApi: Cannot process grants without a signer DID'); - } - - for (const grant of grants) { - const data = Convert.base64Url(grant.encodedData).toArrayBuffer(); - const grantMessage = grant as DwnMessage[DwnInterface.RecordsWrite]; - delete grantMessage['encodedData']; - - const { reply } = await this.agent.processDwnRequest({ - author : this.delegateDid, - target : this.delegateDid, - signAsOwner : true, - messageType : DwnInterface.RecordsWrite, - rawMessage : grantMessage, - dataStream : new Blob([ data ]) - }); - - if (reply.status.code !== 202) { - // if any of the grants fail, delete the other grants and throw an error - for (const grant of grants) { - const { reply } = await this.agent.processDwnRequest({ - author : this.delegateDid, - target : this.delegateDid, - messageType : DwnInterface.RecordsDelete, - messageParams : { - recordId: grant.recordId - } - }); - - if (reply.status.code !== 202 && reply.status.code !== 404) { - console.error('Failed to delete grant: ', grant.recordId); - } - } - - throw new Error(`Failed to process delegated grant: ${reply.status.detail}`); - } - - this.cachedPermissions.clear(); - } + isRevoked: async (grant: DwnPermissionGrant): Promise => { + const author = this.delegateDid ?? this.connectedDid; + return this.permissions.isGrantRevoked(author, grant.grantor, grant.id); + }, + createRequest: async(request :Omit): Promise => { + return this.permissions.createRequest({ + author: this.delegateDid ?? this.connectedDid, + ...request, + }); + }, + createGrant: async(request :Omit): Promise => { + return this.permissions.createGrant({ + author: this.delegateDid ?? this.connectedDid, + ...request, + }); + }, + createRevocation: async(request :Omit): Promise => { + return this.permissions.createRevocation({ + author: this.delegateDid ?? this.connectedDid, + ...request, + }); } }; } @@ -538,7 +511,7 @@ export class DwnApi { if (this.delegateDid) { // if an app is scoped down to a specific protocolPath or contextId, it must include those filters in the read request - const delegatedGrant = await this.grants.findConnectedPermissionGrant({ + const delegatedGrant = await this.connected.findPermissionGrantForRequest({ messageParams: { messageType : DwnInterface.RecordsDelete, protocol : request.protocol, @@ -585,7 +558,7 @@ export class DwnApi { if (this.delegateDid) { // if an app is scoped down to a specific protocolPath or contextId, it must include those filters in the read request - const delegatedGrant = await this.grants.findConnectedPermissionGrant({ + const delegatedGrant = await this.connected.findPermissionGrantForRequest({ messageParams: { messageType : DwnInterface.RecordsQuery, protocol : agentRequest.messageParams.filter.protocol, @@ -661,7 +634,7 @@ export class DwnApi { if (this.delegateDid) { // if an app is scoped down to a specific protocolPath or contextId, it must include those filters in the read request - const delegatedGrant = await this.grants.findConnectedPermissionGrant({ + const delegatedGrant = await this.connected.findPermissionGrantForRequest({ messageParams: { messageType : DwnInterface.RecordsRead, protocol : request.protocol @@ -739,7 +712,7 @@ export class DwnApi { // if impersonation is enabled, fetch the delegated grant to use with the write operation if (this.delegateDid) { - const delegatedGrant = await this.grants.findConnectedPermissionGrant({ + const delegatedGrant = await this.connected.findPermissionGrantForRequest({ messageParams: { messageType : DwnInterface.RecordsWrite, protocol : dwnRequestParams.messageParams.protocol, @@ -780,4 +753,20 @@ export class DwnApi { }, }; } + + static async processConnectedGrants({ grants, agent, connectedDid, delegateDid }: { + grants: DwnDataEncodedRecordsWriteMessage[], + agent: Web5Agent, + connectedDid: string, + delegateDid: string + }): Promise { + const dwnApi = new DwnApi({ agent, connectedDid, delegateDid }); + for (const grant of grants) { + // store the grant as the owner of the DWN, this will allow the delegateDid to use the grant when impersonating the connectedDid + const { status } = await dwnApi.grants.storeGrant({ grant, signAsOwner: true }); + if (status.code !== 202) { + throw new Error(`AgentDwnApi: Failed to process connected grant: ${status.detail}`); + } + } + } } \ No newline at end of file diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index 4c289a7e8..87c9d0114 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -302,16 +302,12 @@ export class Web5 { }}); await userAgent.identity.manage({ portableIdentity: await identity.export() }); - // NOTE: We are using the DwnApi directly temporarily, in a future release there will be a more robust Permissions API on the agent level - // to handle specific permissions requests - // - // Process the incoming delegated grants in the UserAgent as the owner of the signing delegatedDID - // this will allow the delegated DID to fetch the grants in order to use them when selecting a grant to sign a record/message with - // If any of the grants fail to process, they are all rolled back and this will throw an error causing the identity to be cleaned up - const dwnApi = new DwnApi({ agent, connectedDid, delegateDid: delegateDid.uri }); - await dwnApi.grants.processConnectedGrantsAsOwner(delegateGrants); + // Attempts to process the connected grants to be used by the delegateDID + // If the process fails, we want to clean up the identity + await DwnApi.processConnectedGrants({ agent, connectedDid, delegateDid: delegateDid.uri, grants: delegateGrants }); } catch (error:any) { // clean up the DID and Identity if import fails and throw + // TODO: Implement the ability to purge all of our messages as a tenant await this.cleanUpIdentity({ identity, userAgent }); throw new Error(`Failed to connect to wallet: ${error.message}`); } diff --git a/packages/api/tests/dwn-api.spec.ts b/packages/api/tests/dwn-api.spec.ts index 204cc83f3..9ae54ce57 100644 --- a/packages/api/tests/dwn-api.spec.ts +++ b/packages/api/tests/dwn-api.spec.ts @@ -3,13 +3,14 @@ import type { BearerDid } from '@web5/dids'; import sinon from 'sinon'; import { expect } from 'chai'; import { Web5UserAgent } from '@web5/user-agent'; -import { DwnDateSort, DwnInterface, PlatformAgentTestHarness } from '@web5/agent'; +import { DwnDateSort, DwnInterface, DwnPermissionGrant, PlatformAgentTestHarness } from '@web5/agent'; import { DwnApi } from '../src/dwn-api.js'; import { testDwnUrl } from './utils/test-config.js'; import emailProtocolDefinition from './fixtures/protocol-definitions/email.json' assert { type: 'json' }; import photosProtocolDefinition from './fixtures/protocol-definitions/photos.json' assert { type: 'json' }; import { DwnInterfaceName, DwnMethodName, PermissionGrant, Time } from '@tbd54566975/dwn-sdk-js'; +import { Convert } from '@web5/common'; let testDwnUrls: string[] = [testDwnUrl]; @@ -45,10 +46,6 @@ describe('DwnApi', () => { // Instantiate DwnApi for both test identities. dwnAlice = new DwnApi({ agent: testHarness.agent, connectedDid: aliceDid.uri }); dwnBob = new DwnApi({ agent: testHarness.agent, connectedDid: bobDid.uri }); - - // clear cached permissions between test runs - dwnAlice['cachedPermissions'].clear(); - dwnBob['cachedPermissions'].clear(); }); after(async () => { @@ -1370,130 +1367,19 @@ describe('DwnApi', () => { }); }); - describe('grants.fetchConnectedGrants()', () => { - it('throws if no signerDID is set', async () => { - // make sure signerDID is undefined + describe('connected.fetchConnectedGrants()', () => { + it('throws if no delegateDid is set', async () => { + // make sure delegateDid is undefined dwnAlice['delegateDid'] = undefined; try { - await dwnAlice.grants.fetchConnectedGrants(); + await dwnAlice['connected'].fetchConnectedGrants(); expect.fail('Error was not thrown'); } catch (e) { expect(e.message).to.equal('AgentDwnApi: Cannot fetch grants without a signer DID'); } }); - it('caches results', async () => { - // create an identity for deviceX - const aliceDeviceX = await testHarness.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - // set the device identity as the signerDID - dwnAlice['delegateDid'] = aliceDeviceX.did.uri; - - const recordsWriteGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, - grantedTo : aliceDeviceX.did.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Write, protocol: 'http://example.com/protocol' } - }); - - // process the grant to aliceDeviceX's DWN - const { reply: writeReplyX } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsWriteGrant.permissionGrantBytes]), - signAsOwner : true - }); - - expect(writeReplyX.status.code).to.equal(202); - - const recordsReadGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, - grantedTo : aliceDeviceX.did.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Read, protocol: 'http://example.com/protocol' } - }); - - // process the grant to aliceDeviceX's DWN - const { reply: readReplyX } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsReadGrant.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsReadGrant.permissionGrantBytes]), - signAsOwner : true - }); - - expect(readReplyX.status.code).to.equal(202); - - // spy on processDwnRequest to ensure it is only called for the first fetch - const dwnRequestSpy = sinon.spy(testHarness.agent, 'processDwnRequest'); - const grants = await dwnAlice.grants.fetchConnectedGrants(); - - expect(grants).to.exist; - expect(grants.length).to.equal(2); - - // ensure the spy to be called three times, once for fetch and once for each revocation check - expect(dwnRequestSpy.callCount).to.equal(3); - - // get the grants again to ensure they are cached - const cachedGrants = await dwnAlice.grants.fetchConnectedGrants(); - - expect(cachedGrants).to.exist; - expect(cachedGrants.length).to.equal(2); - - // ensure the spy callCount was unchanged - expect(dwnRequestSpy.callCount).to.equal(3); - - // add a new grant to aliceDeviceX - const recordsWriteGrant2 = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, - grantedTo : aliceDeviceX.did.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Write, protocol: 'http://example.com/protocol-two' } - }); - - // process the grant to aliceDeviceX's DWN - const { reply: writeReplyXTwo } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant2.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsWriteGrant2.permissionGrantBytes]), - signAsOwner : true - }); - expect(writeReplyXTwo.status.code).to.equal(202); - - // reset the spy - dwnRequestSpy.resetHistory(); - - // fetch the grants again, the cached results should be returned, and the spy should not be called - const updatedGrants = await dwnAlice.grants.fetchConnectedGrants(); - expect(updatedGrants).to.exist; - expect(updatedGrants.length).to.equal(2); // unchanged - // must not include the new grant - expect(updatedGrants.map(grant => grant.recordId)).to.not.include(recordsWriteGrant2.dataEncodedMessage.recordId); - - // ensure a dwnRequest was not made - expect(dwnRequestSpy.callCount).to.equal(0); - - // now fetch the grants with cache set to false - const updatedGrantsNoCache = await dwnAlice.grants.fetchConnectedGrants(false); - expect(updatedGrantsNoCache).to.exist; - expect(updatedGrantsNoCache.length).to.equal(3); // includes the new grant - // must include the new grant - expect(updatedGrantsNoCache.map(grant => grant.recordId)).to.include(recordsWriteGrant2.dataEncodedMessage.recordId); - - // ensure dwnRequest was called, once for the fetch and once for each revocation check - expect(dwnRequestSpy.callCount).to.equal(4); - }); - - it('fetches grants for the signer', async () => { + it('fetches grants for the delegateDid', async () => { // scenario: alice creates grants for recipients deviceY and deviceX // the grantee fetches their own grants respectively @@ -1504,54 +1390,36 @@ describe('DwnApi', () => { didMethod : 'jwk' }); - // set the device identity as the signerDID, this normally happens when the identity is connected + // set the device identity as the delegateDid, this normally happens when the identity is connected dwnAlice['delegateDid'] = aliceDeviceX.did.uri; - const recordsWriteGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, + const recordsWriteGrant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Write, protocol: 'http://example.com/protocol' } }); - // process the grant to aliceDeviceX's DWN - const { reply: writeReplyX } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsWriteGrant.permissionGrantBytes]), - signAsOwner : true - }); - + const writeReplyX = await dwnAlice.grants.storeGrant({ grant: recordsWriteGrant.message, signAsOwner: true }); expect(writeReplyX.status.code).to.equal(202); - const recordsReadGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, + const recordsReadGrant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Read, protocol: 'http://example.com/protocol' } }); - // process the grant to aliceDeviceX's DWN - const { reply: readReplyX } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsReadGrant.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsReadGrant.permissionGrantBytes]), - signAsOwner : true - }); - + const readReplyX = await dwnAlice.grants.storeGrant({ grant: recordsReadGrant.message, signAsOwner: true }); expect(readReplyX.status.code).to.equal(202); const deviceXGrantRecordIds = [ - recordsWriteGrant.dataEncodedMessage.recordId, - recordsReadGrant.dataEncodedMessage.recordId + recordsWriteGrant.message.recordId, + recordsReadGrant.message.recordId ]; // fetch the grants for deviceX from the app agent - const fetchedDeviceXGrants = await dwnAlice.grants.fetchConnectedGrants(); + const fetchedDeviceXGrants = await dwnAlice['connected'].fetchConnectedGrants(); // expect to have the 5 grants created for deviceX expect(fetchedDeviceXGrants.length).to.equal(2); @@ -1559,13 +1427,13 @@ describe('DwnApi', () => { }); it('should throw if the grant query returns anything other than a 200', async () => { - // setting a signerDID, otherwise fetchConnectedGrants will throw + // setting a delegateDid, otherwise fetchConnectedGrants will throw dwnAlice['delegateDid'] = 'did:example:123'; // return empty array if grant query returns something other than a 200 sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'unknown error' } } }); try { - await dwnAlice.grants.fetchConnectedGrants(); + await dwnAlice['connected'].fetchConnectedGrants(); expect.fail('Expected fetchGrants to throw'); } catch(error: any) { @@ -1577,107 +1445,83 @@ describe('DwnApi', () => { // create an identity for deviceX and deviceY const aliceDeviceX = await testHarness.agent.identity.create({ store : true, - metadata : { name: 'Alice Device X' }, + metadata : { name: 'Alice Device X', connectedDid: aliceDid.uri }, didMethod : 'jwk' }); - // set the device identity as the signerDID for alice, this normally happens during a connect flow + // set the device identity as the delegateDid for alice, this normally happens during a connect flow dwnAlice['delegateDid'] = aliceDeviceX.did.uri; - const recordsWriteGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, + const recordsWriteGrant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Write, protocol: 'http://example.com/protocol' } }); // process the grant to alice's DWN - const { reply: writeReply } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - author : aliceDid.uri, - target : aliceDid.uri, - dataStream : new Blob([recordsWriteGrant.permissionGrantBytes]), - }); + const writeReply = await dwnAlice.grants.storeGrant({ grant: recordsWriteGrant.message }); expect(writeReply.status.code).to.equal(202); // process the grant to aliceDeviceX's DWN as owner - const { reply: writeReplyX } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsWriteGrant.permissionGrantBytes]), - signAsOwner : true - }); + const writeReplyX = await dwnAlice.grants.storeGrant({ grant: recordsWriteGrant.message, signAsOwner: true }); expect(writeReplyX.status.code).to.equal(202); - const recordsReadGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, + const recordsReadGrant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Read, protocol: 'http://example.com/protocol' } }); - // process the grant to alice's DWN - const { reply: readReply } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsReadGrant.recordsWrite.message, - author : aliceDid.uri, - target : aliceDid.uri, - dataStream : new Blob([recordsReadGrant.permissionGrantBytes]), - }); + const readReply = await dwnAlice.grants.storeGrant({ grant: recordsReadGrant.message }); expect(readReply.status.code).to.equal(202); // process the grant to aliceDeviceX's DWN - const { reply: readReplyX } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsReadGrant.recordsWrite.message, - author : aliceDeviceX.did.uri, - target : aliceDeviceX.did.uri, - dataStream : new Blob([recordsReadGrant.permissionGrantBytes]), - signAsOwner : true - }); + const readReplyX = await dwnAlice.grants.storeGrant({ grant: recordsReadGrant.message, signAsOwner: true }); expect(readReplyX.status.code).to.equal(202); - // fetch the grants for deviceX from the app agent with cache set to false - const fetchedDeviceXGrants = await dwnAlice.grants.fetchConnectedGrants(false); + // fetch the grants for deviceX from the app agent + const fetchedDeviceXGrants = await dwnAlice['connected'].fetchConnectedGrants(); // expect to have the 2 grants created for deviceX expect(fetchedDeviceXGrants.length).to.equal(2); // revoke a grant - const writeGrant = await PermissionGrant.parse(recordsWriteGrant.dataEncodedMessage); - const recordsWriteGrantRevoke = await testHarness.agent.dwn.createRevocation({ + const writeGrant = await PermissionGrant.parse(recordsWriteGrant.message); + const recordsWriteGrantRevoke = await testHarness.agent.permissions.createRevocation({ author : aliceDid.uri, grant : writeGrant, }); // process the grant to alice's DWN + const { encodedData: revokeEncodedData, ...revokeMessage } = recordsWriteGrantRevoke.message; const revokeReply = await testHarness.agent.dwn.processRequest({ messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrantRevoke.recordsWrite.message, + rawMessage : revokeMessage, author : aliceDid.uri, target : aliceDid.uri, - dataStream : new Blob([recordsWriteGrantRevoke.permissionRevocationBytes]), + dataStream : new Blob([ Convert.base64Url(revokeEncodedData).toUint8Array() ]), }); expect(revokeReply.reply.status.code).to.equal(202); // fetch the grants for deviceX from the app agent with cache set to false - const fetchedDeviceXGrantsRevoked = await dwnAlice.grants.fetchConnectedGrants(); + const fetchedDeviceXGrantsRevoked = await dwnAlice['connected'].fetchConnectedGrants(); expect(fetchedDeviceXGrantsRevoked.length).to.equal(1); // only the read grant should be available // ensure the revoked grant is not included - expect(fetchedDeviceXGrantsRevoked.map(grant => grant.recordId)).to.not.include(recordsWriteGrant.dataEncodedMessage.recordId); + expect(fetchedDeviceXGrantsRevoked.map(grant => grant.recordId)).to.not.include(recordsWriteGrant.message.recordId); }); }); - describe('grants.findConnectedPermissionGrant', () => { - it('throws if no signerDID is set', async () => { - // make sure signerDID is undefined + describe('connected.findPermissionGrantForRequest', () => { + xit('caches result'); + + it('throws if no delegateDid is set', async () => { + // make sure delegateDid is undefined dwnAlice['delegateDid'] = undefined; try { - await dwnAlice.grants.findConnectedPermissionGrant({ + await dwnAlice['connected'].findPermissionGrantForRequest({ messageParams: { messageType : DwnInterface.RecordsWrite, protocol : 'http://example.com/protocol' @@ -1690,20 +1534,7 @@ describe('DwnApi', () => { }); }); - describe('grants.processConnectedGrantsAsOwner', () => { - it('throws if no signerDID is set', async () => { - // make sure signerDID is undefined - dwnAlice['delegateDid'] = undefined; - try { - await dwnAlice.grants.processConnectedGrantsAsOwner([]); - expect.fail('Error was not thrown'); - } catch (e) { - expect(e.message).to.equal('AgentDwnApi: Cannot process grants without a signer DID'); - } - }); - }); - - describe('grants.isGrantRevoked', () => { + describe('grants.isRevoked', () => { it('checks if grant is revoked', async () => { // scenario: create a grant for deviceX, check if the grant is revoked, revoke the grant, check if the grant is revoked @@ -1715,8 +1546,8 @@ describe('DwnApi', () => { }); // create records grants for deviceX - const deviceXGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, + const deviceXGrant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -1726,58 +1557,56 @@ describe('DwnApi', () => { } }); - const { reply: processGrantReply } = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : deviceXGrant.recordsWrite.message, - author : aliceDid.uri, - target : aliceDid.uri, - dataStream : new Blob([deviceXGrant.permissionGrantBytes]), - }); + const processGrantReply = await dwnAlice.grants.storeGrant({ grant: deviceXGrant.message }); expect(processGrantReply.status.code).to.equal(202); - // check if the grant is revoked - let isRevoked = await dwnAlice.grants.isGrantRevoked( - aliceDid.uri, - aliceDid.uri, - deviceXGrant.recordsWrite.message.recordId - ); + // parse the grant + const writeGrant = await DwnPermissionGrant.parse(deviceXGrant.message); + // check if the grant is revoked + let isRevoked = await dwnAlice.grants.isRevoked(writeGrant); expect(isRevoked).to.equal(false); // revoke the grant - const writeGrant = await PermissionGrant.parse(deviceXGrant.dataEncodedMessage); - const revokeGrant = await testHarness.agent.dwn.createRevocation({ + const revokeGrant = await testHarness.agent.permissions.createRevocation({ author : aliceDid.uri, grant : writeGrant, }); + const { encodedData: revokeGrantEncodedData, ...revokeGrantMessage } = revokeGrant.message; const revokeReply = await testHarness.agent.dwn.processRequest({ messageType : DwnInterface.RecordsWrite, - rawMessage : revokeGrant.recordsWrite.message, + rawMessage : revokeGrantMessage, author : aliceDid.uri, target : aliceDid.uri, - dataStream : new Blob([revokeGrant.permissionRevocationBytes]), + dataStream : new Blob([ Convert.base64Url(revokeGrantEncodedData).toUint8Array() ]), }); expect(revokeReply.reply.status.code).to.equal(202); // check if the grant is revoked again, should be true - isRevoked = await dwnAlice.grants.isGrantRevoked( - aliceDid.uri, - aliceDid.uri, - deviceXGrant.recordsWrite.message.recordId - ); + isRevoked = await dwnAlice.grants.isRevoked(writeGrant); expect(isRevoked).to.equal(true); }); it('throws if grant revocation query returns anything other than a 200 or 404', async () => { // return empty array if grant query returns something other than a 200 sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'unknown error' } } }); - try { - await dwnAlice.grants.isGrantRevoked(aliceDid.uri, aliceDid.uri, 'some-record-id'); - expect.fail('Expected isGrantRevoked to throw'); + await dwnAlice.grants.isRevoked({ + id : 'some-record', + grantee : 'did:example:123', + grantor : 'did:example:456', + dateGranted : Time.getCurrentTimestamp(), + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol', + } + }); + expect.fail('Expected isRevoked to throw'); } catch (error:any) { - expect(error.message).to.equal('AgentDwnApi: Failed to check if grant is revoked: unknown error'); + expect(error.message).to.equal('PermissionsApi: Failed to check if grant is revoked: unknown error'); } }); }); diff --git a/packages/api/tests/web5.spec.ts b/packages/api/tests/web5.spec.ts index a965fa413..a98bde4c4 100644 --- a/packages/api/tests/web5.spec.ts +++ b/packages/api/tests/web5.spec.ts @@ -9,6 +9,7 @@ import { ConnectPlaceholder } from '../src/temp.js'; import { DwnInterfaceName, DwnMethodName, Jws, Time } from '@tbd54566975/dwn-sdk-js'; import { testDwnUrl } from './utils/test-config.js'; import { DidJwk } from '@web5/dids'; +import { DwnApi } from '../src/dwn-api.js'; describe('Web5', () => { describe('using Test Harness', () => { @@ -89,9 +90,9 @@ describe('Web5', () => { }); // create grants for the app to use - const writeGrant = await testHarness.agent.dwn.createGrant({ + const writeGrant = await testHarness.agent.permissions.createGrant({ delegated : true, - grantedFrom : alice.did.uri, + author : alice.did.uri, grantedTo : app.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -101,9 +102,9 @@ describe('Web5', () => { } }); - const readGrant = await testHarness.agent.dwn.createGrant({ + const readGrant = await testHarness.agent.permissions.createGrant({ delegated : true, - grantedFrom : alice.did.uri, + author : alice.did.uri, grantedTo : app.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -113,28 +114,9 @@ describe('Web5', () => { } }); - // write the grants to wallet - const { reply: writeGrantReply } = await testHarness.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : writeGrant.recordsWrite.message, - dataStream : new Blob([ writeGrant.permissionGrantBytes ]) - }); - expect(writeGrantReply.status.code).to.equal(202); - - const { reply: readGrantReply } = await testHarness.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : readGrant.recordsWrite.message, - dataStream : new Blob([ readGrant.permissionGrantBytes ]) - }); - expect(readGrantReply.status.code).to.equal(202); - // stub the walletInit method of the Connect placeholder class sinon.stub(ConnectPlaceholder, 'initClient').resolves({ - delegateGrants : [ writeGrant.dataEncodedMessage, readGrant.dataEncodedMessage ], + delegateGrants : [ writeGrant.message, readGrant.message ], delegateDid : await app.export(), connectedDid : alice.did.uri }); @@ -174,24 +156,6 @@ describe('Web5', () => { }); expect(localProtocolReply.status.code).to.equal(202); - const { reply: grantWriteLocalReply } = await web5.agent.processDwnRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : writeGrant.recordsWrite.message, - dataStream : new Blob([ writeGrant.permissionGrantBytes ]) - }); - expect(grantWriteLocalReply.status.code).to.equal(202); - - const { reply: grantReadLocalReply } = await web5.agent.processDwnRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : readGrant.recordsWrite.message, - dataStream : new Blob([ readGrant.permissionGrantBytes ]) - }); - expect(grantReadLocalReply.status.code).to.equal(202); - // use the grant to write a record const writeResult = await web5.dwn.records.write({ data : 'Hello, world!', @@ -248,9 +212,9 @@ describe('Web5', () => { } // grant query and delete permissions - const queryGrant = await testHarness.agent.dwn.createGrant({ + const queryGrant = await testHarness.agent.permissions.createGrant({ + author : alice.did.uri, delegated : true, - grantedFrom : alice.did.uri, grantedTo : app.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -260,9 +224,9 @@ describe('Web5', () => { } }); - const deleteGrant = await testHarness.agent.dwn.createGrant({ + const deleteGrant = await testHarness.agent.permissions.createGrant({ + author : alice.did.uri, delegated : true, - grantedFrom : alice.did.uri, grantedTo : app.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -274,7 +238,12 @@ describe('Web5', () => { // write the grants to app as owner // this also clears the grants cache - await web5.dwn.grants.processConnectedGrantsAsOwner([ queryGrant.dataEncodedMessage, deleteGrant.dataEncodedMessage ]); + await DwnApi.processConnectedGrants({ + grants : [ queryGrant.message, deleteGrant.message ], + agent : appTestHarness.agent, + connectedDid : alice.did.uri, + delegateDid : app.uri, + }); // attempt to delete using the grant const deleteResult = await web5.dwn.records.delete({ @@ -342,9 +311,9 @@ describe('Web5', () => { }); // create grants for the app to use - const writeGrant = await testHarness.agent.dwn.createGrant({ + const writeGrant = await testHarness.agent.permissions.createGrant({ delegated : true, - grantedFrom : alice.did.uri, + author : alice.did.uri, grantedTo : app.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -354,9 +323,9 @@ describe('Web5', () => { } }); - const readGrant = await testHarness.agent.dwn.createGrant({ + const readGrant = await testHarness.agent.permissions.createGrant({ delegated : true, - grantedFrom : alice.did.uri, + author : alice.did.uri, grantedTo : app.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { @@ -366,28 +335,9 @@ describe('Web5', () => { } }); - // write the grants to wallet - const { reply: writeGrantReply } = await testHarness.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : writeGrant.recordsWrite.message, - dataStream : new Blob([ writeGrant.permissionGrantBytes ]) - }); - expect(writeGrantReply.status.code).to.equal(202); - - const { reply: readGrantReply } = await testHarness.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : readGrant.recordsWrite.message, - dataStream : new Blob([ readGrant.permissionGrantBytes ]) - }); - expect(readGrantReply.status.code).to.equal(202); - // stub the walletInit method of the Connect placeholder class sinon.stub(ConnectPlaceholder, 'initClient').resolves({ - delegateGrants : [ writeGrant.dataEncodedMessage, readGrant.dataEncodedMessage ], + delegateGrants : [ writeGrant.message, readGrant.message ], delegateDid : await app.export(), connectedDid : alice.did.uri }); @@ -410,9 +360,6 @@ describe('Web5', () => { // stub the create method of the Web5UserAgent to use the test harness agent sinon.stub(Web5UserAgent, 'create').resolves(appTestHarness.agent as Web5UserAgent); - // stub console.error so that it doesn't log in the test output and use it as a spy confirming the error messages were logged - const consoleSpy = sinon.stub(console, 'error').returns(); - try { // connect to the app, the options don't matter because we're stubbing the initClient method await Web5.connect({ @@ -426,16 +373,12 @@ describe('Web5', () => { expect.fail('Should have thrown an error'); } catch(error:any) { - expect(error.message).to.equal('Failed to connect to wallet: Failed to process delegated grant: Bad Request'); + expect(error.message).to.equal('Failed to connect to wallet: AgentDwnApi: Failed to process connected grant: Bad Request'); } - // because `processDwnRequest` is stubbed to return a 400, deleting the grants will return the same - // we spy on console.error to check if the error messages are logged for the 2 failed grant deletions - expect(consoleSpy.calledTwice, 'console.error called twice').to.be.true; - // check that the Identity was deleted - const appDid = await appTestHarness.agent.identity.list(); - expect(appDid).to.have.lengthOf(0); + const appIdentities = await appTestHarness.agent.identity.list(); + expect(appIdentities).to.have.lengthOf(0); // close the app test harness storage await appTestHarness.clearStorage();