diff --git a/.changeset/gold-lamps-obey.md b/.changeset/gold-lamps-obey.md new file mode 100644 index 000000000..d4beab164 --- /dev/null +++ b/.changeset/gold-lamps-obey.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +Introduce a `grants` API for `Web5.dwn` diff --git a/.changeset/polite-days-wash.md b/.changeset/polite-days-wash.md new file mode 100644 index 000000000..0ea7caf24 --- /dev/null +++ b/.changeset/polite-days-wash.md @@ -0,0 +1,8 @@ +--- +"@web5/identity-agent": patch +"@web5/proxy-agent": patch +"@web5/user-agent": patch +"@web5/agent": patch +--- + +Introduce a `PermissionsApi` for Web5Agents diff --git a/packages/agent/src/dwn-api.ts b/packages/agent/src/dwn-api.ts index 747421971..fb0614373 100644 --- a/packages/agent/src/dwn-api.ts +++ b/packages/agent/src/dwn-api.ts @@ -2,7 +2,6 @@ import type { Readable } from '@web5/common'; import { Cid, - DataEncodedRecordsWriteMessage, DataStoreLevel, Dwn, DwnConfig, @@ -11,10 +10,6 @@ import { GenericMessage, Message, MessageStoreLevel, - PermissionGrant, - PermissionScope, - PermissionsProtocol, - RecordsWrite, ResumableTaskStoreLevel } from '@tbd54566975/dwn-sdk-js'; @@ -449,43 +444,4 @@ export class AgentDwnApi { return dwnMessageWithBlob; } - - /** - * NOTE EVERYTHING BELOW THIS LINE IS TEMPORARY - * TODO: Create a `grants` API to handle creating permission requests, grants and revocations - * */ - - public async createGrant({ grantedFrom, dateExpires, grantedTo, scope, delegated }:{ - dateExpires: string, - grantedFrom: string, - grantedTo: string, - scope: PermissionScope, - delegated?: boolean - }): Promise<{ - recordsWrite: RecordsWrite, - dataEncodedMessage: DataEncodedRecordsWriteMessage, - permissionGrantBytes: Uint8Array - }> { - return await PermissionsProtocol.createGrant({ - signer: await this.getSigner(grantedFrom), - grantedTo, - dateExpires, - scope, - delegated - }); - } - - public async createRevocation({ grant, author }:{ - author: string, - grant: PermissionGrant - }): Promise<{ - recordsWrite: RecordsWrite, - dataEncodedMessage: DataEncodedRecordsWriteMessage, - permissionRevocationBytes: Uint8Array - }> { - return await PermissionsProtocol.createRevocation({ - signer: await this.getSigner(author), - grant, - }); - } } \ No newline at end of file diff --git a/packages/agent/src/dwn-permissions-util.ts b/packages/agent/src/dwn-permissions-util.ts deleted file mode 100644 index cf97c1cf5..000000000 --- a/packages/agent/src/dwn-permissions-util.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { DataEncodedRecordsWriteMessage, MessagesPermissionScope, PermissionGrant, PermissionScope, PermissionsProtocol, ProtocolPermissionScope, RecordsPermissionScope } from '@tbd54566975/dwn-sdk-js'; -import { DwnInterface } from './types/dwn.js'; -import { isRecordsType } from './dwn-api.js'; - -export class DwnPermissionsUtil { - - static permissionsProtocolParams(type: 'grant' | 'revoke' | 'request'): { protocol: string, protocolPath: string } { - const protocolPath = type === 'grant' ? PermissionsProtocol.grantPath : - type === 'revoke' ? PermissionsProtocol.revocationPath : PermissionsProtocol.requestPath; - return { - protocol: PermissionsProtocol.uri, - protocolPath, - }; - } - - /** - * Matches the appropriate grant from an array of grants based on the provided parameters. - * - * @param delegated if true, only delegated grants are turned, if false all grants are returned including delegated ones. - */ - static async matchGrantFromArray( - grantor: string, - grantee: string, - messageParams: { - messageType: T, - protocol?: string, - protocolPath?: string, - contextId?: string, - }, - grants: DataEncodedRecordsWriteMessage[], - delegated: boolean = false - ): Promise<{ message: DataEncodedRecordsWriteMessage, grant: PermissionGrant } | undefined> { - for (const grant of grants) { - const grantData = await PermissionGrant.parse(grant); - // only delegated grants are returned - if (delegated === true && grantData.delegated !== true) { - continue; - } - const { messageType, protocol, protocolPath, contextId } = messageParams; - - if (this.matchScopeFromGrant(grantor, grantee, messageType, grantData, protocol, protocolPath, contextId)) { - return { message: grant, grant: grantData }; - } - } - } - - private static matchScopeFromGrant( - grantor: string, - grantee: string, - messageType: T, - grant: PermissionGrant, - protocol?: string, - protocolPath?: string, - contextId?: string - ): boolean { - // Check if the grant matches the provided parameters - if (grant.grantee !== grantee || grant.grantor !== grantor) { - return false; - } - - const scope = grant.scope; - const scopeMessageType = scope.interface + scope.method; - if (scopeMessageType === messageType) { - if (isRecordsType(messageType)) { - const recordScope = scope as RecordsPermissionScope; - if (!this.matchesProtocol(recordScope, protocol)) { - return false; - } - - // If the grant scope is not restricted to a specific context or protocol path, it is unrestricted and can be used - if (this.isUnrestrictedProtocolScope(recordScope)) { - return true; - } - - // protocolPath and contextId are mutually exclusive - // If the permission is scoped to a protocolPath and the permissionParams matches that path, this grant can be used - if (recordScope.protocolPath !== undefined && recordScope.protocolPath === protocolPath) { - return true; - } - - // If the permission is scoped to a contextId and the permissionParams starts with that contextId, this grant can be used - if (recordScope.contextId !== undefined && contextId?.startsWith(recordScope.contextId)) { - return true; - } - } else { - const messagesScope = scope as MessagesPermissionScope | ProtocolPermissionScope; - if (this.protocolScopeUnrestricted(messagesScope)) { - return true; - } - - if (!this.matchesProtocol(messagesScope, protocol)) { - return false; - } - - return this.isUnrestrictedProtocolScope(messagesScope); - } - } - - return false; - } - - private static matchesProtocol(scope: PermissionScope & { protocol?: string }, protocol?: string): boolean { - return scope.protocol !== undefined && scope.protocol === protocol; - } - - /** - * Checks if the scope is restricted to a specific protocol - */ - private static protocolScopeUnrestricted(scope: PermissionScope & { protocol?: string }): boolean { - return scope.protocol === undefined; - } - - private static isUnrestrictedProtocolScope(scope: PermissionScope & { contextId?: string, protocolPath?: string }): boolean { - return scope.contextId === undefined && scope.protocolPath === undefined; - } -} \ No newline at end of file diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 4af5f24de..b3f1b3f02 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -3,6 +3,7 @@ export * from './types/dwn.js'; export type * from './types/identity.js'; export type * from './types/identity-vault.js'; export type * from './types/key-manager.js'; +export type * from './types/permissions.js'; export type * from './types/sync.js'; export type * from './types/vc.js'; @@ -10,11 +11,11 @@ export * from './bearer-identity.js'; export * from './crypto-api.js'; export * from './did-api.js'; export * from './dwn-api.js'; -export * from './dwn-permissions-util.js'; export * from './dwn-registrar.js'; export * from './hd-identity-vault.js'; export * from './identity-api.js'; export * from './local-key-manager.js'; +export * from './permissions-api.js'; export * from './rpc-client.js'; export * from './store-data.js'; export * from './store-did.js'; diff --git a/packages/agent/src/permissions-api.ts b/packages/agent/src/permissions-api.ts new file mode 100644 index 000000000..1d2d85cc8 --- /dev/null +++ b/packages/agent/src/permissions-api.ts @@ -0,0 +1,371 @@ +import { PermissionGrant, PermissionGrantData, PermissionRequestData, PermissionRevocationData, PermissionsProtocol } from '@tbd54566975/dwn-sdk-js'; +import { Web5Agent } from './types/agent.js'; +import { DwnDataEncodedRecordsWriteMessage, DwnInterface, DwnMessageParams, DwnMessagesPermissionScope, DwnPermissionGrant, DwnPermissionRequest, DwnPermissionScope, DwnProtocolPermissionScope, DwnRecordsPermissionScope, ProcessDwnRequest } from './types/dwn.js'; +import { Convert } from '@web5/common'; +import { CreateGrantParams, CreateRequestParams, CreateRevocationParams, FetchPermissionRequestParams, FetchPermissionsParams, IsGrantRevokedParams, PermissionGrantEntry, PermissionRequestEntry, PermissionRevocationEntry, PermissionsApi } from './types/permissions.js'; +import { isRecordsType } from './dwn-api.js'; + +export class AgentPermissionsApi implements PermissionsApi { + + private _agent?: Web5Agent; + + get agent(): Web5Agent { + if (!this._agent) { + throw new Error('AgentPermissionsApi: Agent is not set'); + } + return this._agent; + } + + set agent(agent:Web5Agent) { + this._agent = agent; + } + + constructor({ agent }: { agent?: Web5Agent } = {}) { + this._agent = agent; + } + + async fetchGrants({ + author, + target, + grantee, + grantor, + protocol, + remote = false + }: FetchPermissionsParams): Promise { + + // filter by a protocol using tags if provided + const tags = protocol ? { protocol } : undefined; + + const params: ProcessDwnRequest = { + author : author, + target : target, + messageType : DwnInterface.RecordsQuery, + messageParams : { + filter: { + author : grantor, // the author of the grant would be the grantor + recipient : grantee, // the recipient of the grant would be the grantee + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.grantPath, + tags + } + } + }; + + const { reply } = remote ? await this.agent.sendDwnRequest(params) : await this.agent.processDwnRequest(params); + if (reply.status.code !== 200) { + throw new Error(`PermissionsApi: Failed to fetch grants: ${reply.status.detail}`); + } + + const grants:PermissionGrantEntry[] = []; + for (const entry of reply.entries! as DwnDataEncodedRecordsWriteMessage[]) { + // TODO: Check for revocation status based on a request parameter and filter out revoked grants + const grant = await DwnPermissionGrant.parse(entry); + grants.push({ grant, message: entry }); + } + + return grants; + } + + async fetchRequests({ + author, + target, + protocol, + remote = false + }:FetchPermissionRequestParams):Promise { + // filter by a protocol using tags if provided + const tags = protocol ? { protocol } : undefined; + + const params: ProcessDwnRequest = { + author : author, + target : target, + messageType : DwnInterface.RecordsQuery, + messageParams : { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.requestPath, + tags + } + } + }; + + const { reply } = remote ? await this.agent.sendDwnRequest(params) : await this.agent.processDwnRequest(params); + if (reply.status.code !== 200) { + throw new Error(`PermissionsApi: Failed to fetch requests: ${reply.status.detail}`); + } + + const requests: PermissionRequestEntry[] = []; + for (const entry of reply.entries! as DwnDataEncodedRecordsWriteMessage[]) { + const request = await DwnPermissionRequest.parse(entry); + requests.push({ request, message: entry }); + } + + return requests; + } + + async isGrantRevoked({ + author, + target, + grantRecordId, + remote = false + }: IsGrantRevokedParams): Promise { + const params: ProcessDwnRequest = { + author, + target, + messageType : DwnInterface.RecordsRead, + messageParams : { + filter: { + parentId : grantRecordId, + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.revocationPath, + } + } + }; + + const { reply: revocationReply } = remote ? await this.agent.sendDwnRequest(params) : await this.agent.processDwnRequest(params); + 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(`PermissionsApi: Failed to check if grant is revoked: ${revocationReply.status.detail}`); + } + + async createGrant(params: CreateGrantParams): Promise { + const { author, store = false, delegated = false, ...createGrantParams } = params; + + let tags = undefined; + if (PermissionsProtocol.hasProtocolScope(createGrantParams.scope)) { + tags = { protocol: createGrantParams.scope.protocol }; + } + + const permissionGrantData: PermissionGrantData = { + dateExpires : createGrantParams.dateExpires, + requestId : createGrantParams.requestId, + description : createGrantParams.description, + delegated, + scope : createGrantParams.scope + }; + + const permissionsGrantBytes = Convert.object(permissionGrantData).toUint8Array(); + + const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { + recipient : createGrantParams.grantedTo, + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.grantPath, + dataFormat : 'application/json', + tags + }; + + const { reply, message } = await this.agent.processDwnRequest({ + store, + author, + target : author, + messageType : DwnInterface.RecordsWrite, + messageParams, + dataStream : new Blob([ permissionsGrantBytes ]) + }); + + if (reply.status.code !== 202) { + throw new Error(`PermissionsApi: Failed to create grant: ${reply.status.detail}`); + } + + const dataEncodedMessage: DwnDataEncodedRecordsWriteMessage = { + ...message!, + encodedData: Convert.uint8Array(permissionsGrantBytes).toBase64Url() + }; + + const grant = await DwnPermissionGrant.parse(dataEncodedMessage); + + return { grant, message: dataEncodedMessage }; + } + + async createRequest(params: CreateRequestParams): Promise { + const { author, store = false, delegated = false, ...createGrantParams } = params; + + let tags = undefined; + if (PermissionsProtocol.hasProtocolScope(createGrantParams.scope)) { + tags = { protocol: createGrantParams.scope.protocol }; + } + + const permissionRequestData: PermissionRequestData = { + description : createGrantParams.description, + delegated, + scope : createGrantParams.scope + }; + + const permissionRequestBytes = Convert.object(permissionRequestData).toUint8Array(); + + const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.requestPath, + dataFormat : 'application/json', + tags + }; + + const { reply, message } = await this.agent.processDwnRequest({ + store, + author, + target : author, + messageType : DwnInterface.RecordsWrite, + messageParams, + dataStream : new Blob([ permissionRequestBytes ]) + }); + + if (reply.status.code !== 202) { + throw new Error(`PermissionsApi: Failed to create request: ${reply.status.detail}`); + } + + const dataEncodedMessage: DwnDataEncodedRecordsWriteMessage = { + ...message!, + encodedData: Convert.uint8Array(permissionRequestBytes).toBase64Url() + }; + + const request = await DwnPermissionRequest.parse(dataEncodedMessage); + + return { request, message: dataEncodedMessage }; + } + + async createRevocation(params: CreateRevocationParams): Promise { + const { author, store = false, grant, description } = params; + + const revokeData: PermissionRevocationData = { description }; + + const permissionRevocationBytes = Convert.object(revokeData).toUint8Array(); + + let tags = undefined; + if (PermissionsProtocol.hasProtocolScope(grant.scope)) { + tags = { protocol: grant.scope.protocol }; + } + + const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { + parentContextId : grant.id, + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.revocationPath, + dataFormat : 'application/json', + tags + }; + + const { reply, message } = await this.agent.processDwnRequest({ + store, + author, + target : author, + messageType : DwnInterface.RecordsWrite, + messageParams, + dataStream : new Blob([ permissionRevocationBytes ]) + }); + + if (reply.status.code !== 202) { + throw new Error(`PermissionsApi: Failed to create revocation: ${reply.status.detail}`); + } + + const dataEncodedMessage: DwnDataEncodedRecordsWriteMessage = { + ...message!, + encodedData: Convert.uint8Array(permissionRevocationBytes).toBase64Url() + }; + + return { message: dataEncodedMessage }; + } + + /** + * Matches the appropriate grant from an array of grants based on the provided parameters. + * + * @param delegated if true, only delegated grants are turned, if false all grants are returned including delegated ones. + */ + static async matchGrantFromArray( + grantor: string, + grantee: string, + messageParams: { + messageType: T, + protocol?: string, + protocolPath?: string, + contextId?: string, + }, + grants: PermissionGrantEntry[], + delegated: boolean = false + ): Promise { + for (const entry of grants) { + const { grant, message } = entry; + if (delegated === true && grant.delegated !== true) { + continue; + } + const { messageType, protocol, protocolPath, contextId } = messageParams; + + if (this.matchScopeFromGrant(grantor, grantee, messageType, grant, protocol, protocolPath, contextId)) { + return { grant, message }; + } + } + } + + private static matchScopeFromGrant( + grantor: string, + grantee: string, + messageType: T, + grant: PermissionGrant, + protocol?: string, + protocolPath?: string, + contextId?: string + ): boolean { + // Check if the grant matches the provided parameters + if (grant.grantee !== grantee || grant.grantor !== grantor) { + return false; + } + + const scope = grant.scope; + const scopeMessageType = scope.interface + scope.method; + if (scopeMessageType === messageType) { + if (isRecordsType(messageType)) { + const recordScope = scope as DwnRecordsPermissionScope; + if (!this.matchesProtocol(recordScope, protocol)) { + return false; + } + + // If the grant scope is not restricted to a specific context or protocol path, it is unrestricted and can be used + if (this.isUnrestrictedProtocolScope(recordScope)) { + return true; + } + + // protocolPath and contextId are mutually exclusive + // If the permission is scoped to a protocolPath and the permissionParams matches that path, this grant can be used + if (recordScope.protocolPath !== undefined && recordScope.protocolPath === protocolPath) { + return true; + } + + // If the permission is scoped to a contextId and the permissionParams starts with that contextId, this grant can be used + if (recordScope.contextId !== undefined && contextId?.startsWith(recordScope.contextId)) { + return true; + } + } else { + const messagesScope = scope as DwnMessagesPermissionScope | DwnProtocolPermissionScope; + if (this.protocolScopeUnrestricted(messagesScope)) { + return true; + } + + if (!this.matchesProtocol(messagesScope, protocol)) { + return false; + } + + return this.isUnrestrictedProtocolScope(messagesScope); + } + } + + return false; + } + + private static matchesProtocol(scope: DwnPermissionScope & { protocol?: string }, protocol?: string): boolean { + return scope.protocol !== undefined && scope.protocol === protocol; + } + + /** + * Checks if the scope is restricted to a specific protocol + */ + private static protocolScopeUnrestricted(scope: DwnPermissionScope & { protocol?: string }): boolean { + return scope.protocol === undefined; + } + + private static isUnrestrictedProtocolScope(scope: DwnPermissionScope & { contextId?: string, protocolPath?: string }): boolean { + return scope.contextId === undefined && scope.protocolPath === undefined; + } +} \ No newline at end of file diff --git a/packages/agent/src/test-harness.ts b/packages/agent/src/test-harness.ts index 40e02881f..e17dc8df0 100644 --- a/packages/agent/src/test-harness.ts +++ b/packages/agent/src/test-harness.ts @@ -22,6 +22,7 @@ import { DwnDidStore, InMemoryDidStore } from './store-did.js'; import { DwnKeyStore, InMemoryKeyStore } from './store-key.js'; import { DwnIdentityStore, InMemoryIdentityStore } from './store-identity.js'; import { DidResolverCacheMemory } from './prototyping/dids/resolver-cache-memory.js'; +import { AgentPermissionsApi } from './permissions-api.js'; type PlatformAgentTestHarnessParams = { agent: Web5PlatformAgent @@ -104,10 +105,11 @@ export class PlatformAgentTestHarness { // Easiest way to start with fresh in-memory stores is to re-instantiate Agent components. if (this.agentStores === 'memory') { - const { didApi, identityApi, keyManager } = PlatformAgentTestHarness.useMemoryStores({ agent: this.agent }); + const { didApi, identityApi, permissionsApi, keyManager } = PlatformAgentTestHarness.useMemoryStores({ agent: this.agent }); this.agent.did = didApi; this.agent.identity = identityApi; this.agent.keyManager = keyManager; + this.agent.permissions = permissionsApi; } } @@ -203,7 +205,8 @@ export class PlatformAgentTestHarness { identityApi, keyManager, didResolverCache, - vaultStore + vaultStore, + permissionsApi } = (agentStores === 'memory') ? PlatformAgentTestHarness.useMemoryStores() : PlatformAgentTestHarness.useDiskStores({ testDataLocation, stores: dwnStores }); @@ -247,6 +250,7 @@ export class PlatformAgentTestHarness { dwnApi, identityApi, keyManager, + permissionsApi, rpcClient, syncApi, }); @@ -298,7 +302,9 @@ export class PlatformAgentTestHarness { const keyManager = new LocalKeyManager({ agent, keyStore: keyStore }); - return { agentVault, didApi, didResolverCache, identityApi, keyManager, vaultStore }; + const permissionsApi = new AgentPermissionsApi({ agent }); + + return { agentVault, didApi, didResolverCache, identityApi, keyManager, permissionsApi, vaultStore }; } private static useMemoryStores({ agent }: { agent?: Web5PlatformAgent } = {}) { @@ -319,6 +325,8 @@ export class PlatformAgentTestHarness { const identityApi = new AgentIdentityApi({ agent, store: new InMemoryIdentityStore() }); - return { agentVault, didApi, didResolverCache, identityApi, keyManager, vaultStore }; + const permissionsApi = new AgentPermissionsApi({ agent }); + + return { agentVault, didApi, didResolverCache, identityApi, keyManager, permissionsApi, vaultStore }; } } \ No newline at end of file diff --git a/packages/agent/src/types/agent.ts b/packages/agent/src/types/agent.ts index 1d99c1f2b..c5106a3f8 100644 --- a/packages/agent/src/types/agent.ts +++ b/packages/agent/src/types/agent.ts @@ -7,6 +7,7 @@ import type { AgentCryptoApi } from '../crypto-api.js'; import type { AgentKeyManager } from './key-manager.js'; import type { IdentityVault } from './identity-vault.js'; import type { AgentIdentityApi } from '../identity-api.js'; +import type { AgentPermissionsApi } from '../permissions-api.js'; import type { ProcessVcRequest, SendVcRequest, VcResponse } from './vc.js'; import type { AgentDidApi, DidInterface, DidRequest, DidResponse } from '../did-api.js'; import type { DwnInterface, DwnResponse, ProcessDwnRequest, SendDwnRequest } from './dwn.js'; @@ -151,6 +152,11 @@ export interface Web5PlatformAgent Promise; + + /** + * Fetch all requests for a given author and target, optionally filtered by a specific protocol. + */ + fetchRequests: (params: FetchPermissionRequestParams) => Promise; + + /** + * Check whether a grant is revoked by reading the revocation record for a given grant recordId. + */ + isGrantRevoked: (request: IsGrantRevokedParams) => Promise; + + /** + * Create a new permission grant, optionally storing it in the DWN. + */ + createGrant:(params: CreateGrantParams) => Promise; + + /** + * Create a new permission request, optionally storing it in the DWN. + */ + createRequest(params: CreateRequestParams): Promise; + + /** + * Create a new permission revocation, optionally storing it in the DWN. + */ + createRevocation(params: CreateRevocationParams): Promise; +} diff --git a/packages/agent/tests/connected-permissions.spec.ts b/packages/agent/tests/connected-permissions.spec.ts deleted file mode 100644 index 24e104d6c..000000000 --- a/packages/agent/tests/connected-permissions.spec.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { DwnInterfaceName, DwnMethodName, Jws, Message, ProtocolDefinition, Time } from '@tbd54566975/dwn-sdk-js'; - -import type { BearerIdentity } from '../src/bearer-identity.js'; - -import { TestAgent } from './utils/test-agent.js'; -import { DwnInterface, ProcessDwnRequest } from '../src/types/dwn.js'; -import { testDwnUrl } from './utils/test-config.js'; -import { PlatformAgentTestHarness } from '../src/test-harness.js'; - -import sinon from 'sinon'; - -import { expect } from 'chai'; - -// NOTE: @noble/secp256k1 requires globalThis.crypto polyfill for node.js <=18: https://github.com/paulmillr/noble-secp256k1/blob/main/README.md#usage -// Remove when we move off of node.js v18 to v20, earliest possible time would be Oct 2023: https://github.com/nodejs/release#release-schedule -import { webcrypto } from 'node:crypto'; -// @ts-expect-error - globalThis.crypto and webcrypto are of different types. -if (!globalThis.crypto) globalThis.crypto = webcrypto; - -let testDwnUrls: string[] = [testDwnUrl]; - -describe('Connect Flow Permissions', () => { - let aliceAgent: PlatformAgentTestHarness; - let appAgent: PlatformAgentTestHarness; - - before(async () => { - aliceAgent = await PlatformAgentTestHarness.setup({ - agentClass : TestAgent, - agentStores : 'dwn' - }); - - appAgent = await PlatformAgentTestHarness.setup({ - agentClass : TestAgent, - agentStores : 'dwn', - testDataLocation : '__TESTDATA__/app' // Use a different data location for the app - }); - }); - - after(async () => { - await aliceAgent.clearStorage(); - await aliceAgent.closeStorage(); - - await appAgent.clearStorage(); - await appAgent.closeStorage(); - }); - - describe('with Web5 Platform Agent', () => { - let alice: BearerIdentity; - - before(async () => { - await aliceAgent.clearStorage(); - await aliceAgent.createAgentDid(); - - await appAgent.clearStorage(); - await appAgent.createAgentDid(); - }); - - beforeEach(async () => { - sinon.restore(); - - await aliceAgent.syncStore.clear(); - await aliceAgent.dwnDataStore.clear(); - await aliceAgent.dwnEventLog.clear(); - await aliceAgent.dwnMessageStore.clear(); - await aliceAgent.dwnResumableTaskStore.clear(); - aliceAgent.dwnStores.clear(); - - await appAgent.syncStore.clear(); - await appAgent.dwnDataStore.clear(); - await appAgent.dwnEventLog.clear(); - await appAgent.dwnMessageStore.clear(); - await appAgent.dwnResumableTaskStore.clear(); - appAgent.dwnStores.clear(); - - // create and manage alice identity for the tests to use - alice = await aliceAgent.createIdentity({ name: 'Alice', testDwnUrls }); - await aliceAgent.agent.identity.manage({ portableIdentity: await alice.export() }); - }); - - after(async () => { - await aliceAgent.clearStorage(); - await appAgent.clearStorage(); - }); - - it('creates and signs a message with a permission grant', async () => { - // scenario: - // an app creates an identity and manages it within it's own agent - // alice creates a permission grant for the app identity to allow MessageQuery on her DWN - // the app processes the permission grant - // the app then attempts to MessagesQuery using the permission grant on both the local app's DWN and alice's remote DWN - - // create a new identity for the app - const appX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Device X' }, - didMethod : 'jwk' - }); - - await appAgent.agent.identity.manage({ portableIdentity: await appX.export() }); - - // alice creates a permission grant - const messagesQueryGrant = await aliceAgent.agent.dwn.createGrant({ - grantedFrom : alice.did.uri, - grantedTo : appX.did.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Query } - }); - - // alice stores and processes the permission grant on her DWN - const { reply: aliceGrantReply } = await aliceAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrant.recordsWrite.message, - author : alice.did.uri, - target : alice.did.uri, - dataStream : new Blob([messagesQueryGrant.permissionGrantBytes]), - }); - expect(aliceGrantReply.status.code).to.equal(202); - - // The App processes the permission grant given by Alice, so it can be accessible when using it - const { reply: appAgentGrantReply } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrant.recordsWrite.message, - author : alice.did.uri, - target : alice.did.uri, - dataStream : new Blob([messagesQueryGrant.permissionGrantBytes]), - }); - expect(appAgentGrantReply.status.code).to.equal(202); - - const writeGrantToGrantee: ProcessDwnRequest = { - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrant.recordsWrite.message, - author : appX.did.uri, - target : appX.did.uri, - dataStream : new Blob([messagesQueryGrant.permissionGrantBytes]), - signAsOwner : true - }; - - const { reply: importGrantReply } = await appAgent.agent.dwn.processRequest(writeGrantToGrantee); - expect(importGrantReply.status.code).to.equal(202); - - // Attempt to process the MessagesQuery locally using the permission grant. - const { message: queryMessage, reply } = await appAgent.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.MessagesQuery, - messageParams : { - filters : [], - permissionGrantId : messagesQueryGrant.recordsWrite.message.recordId - }, - granteeDid: appX.did.uri, - }); - const messageSignature = queryMessage!.authorization.signature.signatures[0]; - const signatureDid = Jws.getSignerDid(messageSignature); - - expect(signatureDid).to.equal(appX.did.uri); - expect(reply.status.code).to.equal(200); - expect(reply.entries?.length).to.equal(1); // the permission grant should exist - expect(reply.entries![0]).to.equal(await Message.getCid(messagesQueryGrant.recordsWrite.message)); - - // process the message on alice's agent - const { reply: aliceReply } = await aliceAgent.agent.dwn.processRequest({ - messageType : DwnInterface.MessagesQuery, - rawMessage : queryMessage, - author : alice.did.uri, - target : alice.did.uri, - }); - expect(aliceReply.status.code).to.equal(200); - - // should have more than 1 message - // the other messages are related to DID and Identity information stored on the DWN - expect(aliceReply.entries?.length).to.be.gt(1); - expect(aliceReply.entries).to.include(await Message.getCid(messagesQueryGrant.recordsWrite.message)); - }); - - it('creates and signs a delegated grant message', async () => { - // Scenario: - // alice wants to grant permission to an app to write records to her DWN on her behalf - // alice creates an identity for the app - // alice installs the protocol that app will use to write to her DWN - // alice creates a delegated permission grant for RecordsWrite scoped to the protocol of the app - // alice processes the permission grant - // the app is able to write to alice's DWN using the delegated permission grant - // the app attempts to read the record it wrote to alice's DWN, but without a permission grant for RecordsRead, it should fail - // alice issues a delegated permission grant for RecordsRead and the app uses it to read the record - - // alice installs a protocol for deviceX to use to write to her DWN - const protocol: ProtocolDefinition = { - protocol : 'http://example.com/protocol', - published : true, - types : { - foo: {} - }, - structure: { - foo: {} - } - }; - const { reply, message: protocolConfigureMessage } = await aliceAgent.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.ProtocolsConfigure, - messageParams : { - definition: protocol, - } - }); - expect(reply.status.code).to.equal(202); - - // create a new identity for the app - const appX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'App Identity X' }, - didMethod : 'jwk' - }); - - await appAgent.agent.identity.manage({ portableIdentity: await appX.export() }); - - // alice creates a delegated permission grant - const recordsWriteGrant = await aliceAgent.agent.dwn.createGrant({ - grantedFrom : alice.did.uri, - grantedTo : appX.did.uri, - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Write, - protocol : protocol.protocol - } - }); - - // alice stores and processes the permission grant on her DWN - const { reply: aliceGrantReply } = await aliceAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - author : alice.did.uri, - target : alice.did.uri, - dataStream : new Blob([ recordsWriteGrant.permissionGrantBytes ]), - }); - expect(aliceGrantReply.status.code).to.equal(202); - - // alice hands off the grant to the appX agent - // if sync is initiated, the appX will also have the protocol message installed - // but for this test we will process it manually - const { reply: appXProtocolReply } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.ProtocolsConfigure, - rawMessage : protocolConfigureMessage, - author : alice.did.uri, - target : alice.did.uri, - }); - expect(appXProtocolReply.status.code).to.equal(202); - - - // The App processes the permission grant given by Alice, so it can be accessible when using it - const { reply: appAgentGrantReply } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - author : appX.did.uri, - target : appX.did.uri, - dataStream : new Blob([ recordsWriteGrant.permissionGrantBytes ]), - signAsOwner : true - }); - expect(appAgentGrantReply.status.code).to.equal(202); - - // write a record to the app's DWN as alice - const data = new Blob([ 'Hello, Alice!' ]); - const { message: delegatedWriteMessage, reply: delegatedWriteReply } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - messageParams : { - protocol : protocol.protocol, - protocolPath : 'foo', - dataFormat : 'application/json', - delegatedGrant : recordsWriteGrant.dataEncodedMessage - }, - author : alice.did.uri, - target : alice.did.uri, - dataStream : data, - granteeDid : appX.did.uri, - }); - expect(delegatedWriteReply.status.code).to.equal(202, 'delegated write'); - - // write the record to alice's DWN - const { reply: aliceReply } = await aliceAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : delegatedWriteMessage, - author : alice.did.uri, - target : alice.did.uri, - dataStream : data, - }); - expect(aliceReply.status.code).to.equal(202, 'delegated write to alice'); - - //Record Read will not work because the permission grant is for RecordsWrite - const { reply: delegatedReadReply } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsRead, - messageParams : { - filter: { - protocol : protocol.protocol, - protocolPath : 'foo' - }, - delegatedGrant: recordsWriteGrant.dataEncodedMessage, - }, - author : alice.did.uri, - target : alice.did.uri, - granteeDid : appX.did.uri, - }); - expect(delegatedReadReply.status.code).to.equal(401, 'delegated read'); - - // alice issues a delegated read permission grant - const recordReadGrant = await aliceAgent.agent.dwn.createGrant({ - grantedFrom : alice.did.uri, - grantedTo : appX.did.uri, - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Read, - protocol : protocol.protocol - } - }); - - // alice stores and processes the permission grant on her DWN - const { reply: aliceGrantReadReply } = await aliceAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordReadGrant.recordsWrite.message, - author : alice.did.uri, - target : alice.did.uri, - dataStream : new Blob([ recordReadGrant.permissionGrantBytes]), - }); - expect(aliceGrantReadReply.status.code).to.equal(202); - - // alice hands off the grant to the appX agent - const { reply: appAgentGrantReadReply } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordReadGrant.recordsWrite.message, - author : appX.did.uri, - target : appX.did.uri, - dataStream : new Blob([ recordReadGrant.permissionGrantBytes ]), - signAsOwner : true - }); - expect(appAgentGrantReadReply.status.code).to.equal(202); - - // appX now attempts to read the messages using the delegated read permission grant - const { reply: delegatedReadReply2 } = await appAgent.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsRead, - messageParams : { - filter: { - protocol : protocol.protocol, - protocolPath : 'foo' - }, - delegatedGrant: recordReadGrant.dataEncodedMessage, - }, - author : alice.did.uri, - target : alice.did.uri, - granteeDid : appX.did.uri, - }); - expect(delegatedReadReply2.status.code).to.equal(200, 'delegated read ok'); - expect(delegatedReadReply2.record?.recordId).to.equal(delegatedWriteMessage?.recordId); - }); - }); -}); \ No newline at end of file diff --git a/packages/agent/tests/dwn-api.spec.ts b/packages/agent/tests/dwn-api.spec.ts index 95a76a245..9ab4aca3f 100644 --- a/packages/agent/tests/dwn-api.spec.ts +++ b/packages/agent/tests/dwn-api.spec.ts @@ -759,25 +759,14 @@ describe('AgentDwnApi', () => { }); // create teh grant - const recordsWriteDelegateGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : alice.did.uri, + const recordsWriteDelegateGrant = await testHarness.agent.permissions.createGrant({ + author : alice.did.uri, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), delegated : true, scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Write, protocol: protocolDefinition.protocol } }); - // process the grant on alice's DWN - let { reply: { status: grantStatus } } = await testHarness.agent.dwn.processRequest({ - author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteDelegateGrant.recordsWrite.message, - dataStream : new Blob([ recordsWriteDelegateGrant.permissionGrantBytes ]), - }); - expect(grantStatus.code).to.equal(202, 'grant write'); - - // bob authors a public record to his dwn const dataStream = new Blob([ Convert.string('Hello, world!').toUint8Array() ]); @@ -846,7 +835,7 @@ describe('AgentDwnApi', () => { granteeDid : aliceDeviceX.did.uri, messageParams : { dataFormat : 'text/plain', // TODO: not necessary - delegatedGrant : recordsWriteDelegateGrant.dataEncodedMessage, + delegatedGrant : recordsWriteDelegateGrant.message, }, dataStream, }); diff --git a/packages/agent/tests/dwn-permissions-util.spec.ts b/packages/agent/tests/dwn-permissions-util.spec.ts deleted file mode 100644 index 0993052e5..000000000 --- a/packages/agent/tests/dwn-permissions-util.spec.ts +++ /dev/null @@ -1,593 +0,0 @@ - -import type { BearerIdentity } from '../src/bearer-identity.js'; - -import { TestAgent } from './utils/test-agent.js'; -import { DwnInterface } from '../src/types/dwn.js'; -import { testDwnUrl } from './utils/test-config.js'; -import { PlatformAgentTestHarness } from '../src/test-harness.js'; - -import sinon from 'sinon'; - -import { expect } from 'chai'; - -// NOTE: @noble/secp256k1 requires globalThis.crypto polyfill for node.js <=18: https://github.com/paulmillr/noble-secp256k1/blob/main/README.md#usage -// Remove when we move off of node.js v18 to v20, earliest possible time would be Oct 2023: https://github.com/nodejs/release#release-schedule -import { webcrypto } from 'node:crypto'; -import { GrantsUtil } from './utils/grants.js'; -import { DwnPermissionsUtil } from '../src/dwn-permissions-util.js'; -import { PermissionsProtocol } from '@tbd54566975/dwn-sdk-js'; -// @ts-expect-error - globalThis.crypto and webcrypto are of different types. -if (!globalThis.crypto) globalThis.crypto = webcrypto; - -let testDwnUrls: string[] = [testDwnUrl]; - -describe('DwnPermissionsUtil', () => { - describe('permissionsProtocolParams', () => { - it('returns correct params to use in a records message', async () => { - const grantsParams = DwnPermissionsUtil.permissionsProtocolParams('grant'); - expect(grantsParams.protocol).to.equal(PermissionsProtocol.uri); - expect(grantsParams.protocolPath).to.equal(PermissionsProtocol.grantPath); - - const revokeParams = DwnPermissionsUtil.permissionsProtocolParams('revoke'); - expect(revokeParams.protocol).to.equal(PermissionsProtocol.uri); - expect(revokeParams.protocolPath).to.equal(PermissionsProtocol.revocationPath); - - const requestParams = DwnPermissionsUtil.permissionsProtocolParams('request'); - expect(requestParams.protocol).to.equal(PermissionsProtocol.uri); - expect(requestParams.protocolPath).to.equal(PermissionsProtocol.requestPath); - }); - }); - - describe('matchGrantFromArray', () => { - let aliceAgent: PlatformAgentTestHarness; - let appAgent: PlatformAgentTestHarness; - let alice: BearerIdentity; - - before(async () => { - aliceAgent = await PlatformAgentTestHarness.setup({ - agentClass : TestAgent, - agentStores : 'dwn' - }); - - appAgent = await PlatformAgentTestHarness.setup({ - agentClass : TestAgent, - agentStores : 'dwn', - testDataLocation : '__TESTDATA__/app' // Use a different data location for the app - }); - - await aliceAgent.createAgentDid(); - await appAgent.createAgentDid(); - }); - - after(async () => { - sinon.restore(); - - await aliceAgent.clearStorage(); - await aliceAgent.closeStorage(); - - await appAgent.clearStorage(); - await appAgent.closeStorage(); - }); - - beforeEach(async () => { - sinon.restore(); - - await aliceAgent.syncStore.clear(); - await aliceAgent.dwnDataStore.clear(); - await aliceAgent.dwnEventLog.clear(); - await aliceAgent.dwnMessageStore.clear(); - await aliceAgent.dwnResumableTaskStore.clear(); - aliceAgent.dwnStores.clear(); - - await appAgent.syncStore.clear(); - await appAgent.dwnDataStore.clear(); - await appAgent.dwnEventLog.clear(); - await appAgent.dwnMessageStore.clear(); - await appAgent.dwnResumableTaskStore.clear(); - appAgent.dwnStores.clear(); - - // create and manage alice identity for the tests to use - alice = await aliceAgent.createIdentity({ name: 'Alice', testDwnUrls }); - await aliceAgent.agent.identity.manage({ portableIdentity: await alice.export() }); - }); - - it('does not match a grant with a different grantee or grantor', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const aliceDeviceY = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device Y' }, - didMethod : 'jwk' - }); - - const protocol = 'http://example.com/protocol'; - - const deviceXRecordGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol - }); - - const deviceXGranteeGrants = [ - deviceXRecordGrants.write, - deviceXRecordGrants.read, - deviceXRecordGrants.delete, - deviceXRecordGrants.query, - deviceXRecordGrants.subscribe - ]; - - // attempt to match a grant with a different grantee, aliceDeviceY - const notFoundGrantee = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceY.did.uri, { - messageType: DwnInterface.RecordsWrite, - protocol - }, deviceXGranteeGrants); - - expect(notFoundGrantee).to.be.undefined; - - const deviceYRecordGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : appAgent.agent, - granteeAgent : appAgent.agent, - grantor : aliceDeviceX.did.uri, - grantee : aliceDeviceY.did.uri, - protocol - }); - - const deviceYGrantorGrants = [ - deviceYRecordGrants.write, - deviceYRecordGrants.read, - deviceYRecordGrants.delete, - deviceYRecordGrants.query, - deviceYRecordGrants.subscribe - ]; - - const notFoundGrantor = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceY.did.uri, { - messageType: DwnInterface.RecordsWrite, - protocol - }, deviceYGrantorGrants); - - expect(notFoundGrantor).to.be.undefined; - }); - - it('matches delegated grants if specified', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const messagesGrants = await GrantsUtil.createMessagesGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - }); - - const aliceDeviceXMessageGrants = [ - messagesGrants.query, - messagesGrants.read, - messagesGrants.subscribe - ]; - - // control: match a grant without specifying delegated - const queryGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesQuery, - }, aliceDeviceXMessageGrants); - - expect(queryGrant?.message.recordId).to.equal(messagesGrants.query.recordId); - - // attempt to match non-delegated grant with delegated set to true - const notFoundDelegated = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesQuery, - }, aliceDeviceXMessageGrants, true); - - expect(notFoundDelegated).to.be.undefined; - - // create delegated record grants - const protocol = 'http://example.com/protocol'; - const recordsGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol - }); - - const deviceXRecordGrants = [ - recordsGrants.write, - recordsGrants.read, - recordsGrants.delete, - recordsGrants.query, - recordsGrants.subscribe - ]; - - // match a delegated grant - const writeGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.RecordsWrite, - protocol - }, deviceXRecordGrants, true); - - expect(writeGrant?.message.recordId).to.equal(recordsGrants.write.recordId); - }); - - it('Messages', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const messageGrants = await GrantsUtil.createMessagesGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri - }); - - const deviceXMessageGrants = [ - messageGrants.query, - messageGrants.read, - messageGrants.subscribe - ]; - - const queryGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesQuery, - }, deviceXMessageGrants); - - expect(queryGrant?.message.recordId).to.equal(messageGrants.query.recordId); - - const readGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesRead, - }, deviceXMessageGrants); - - expect(readGrant?.message.recordId).to.equal(messageGrants.read.recordId); - - const subscribeGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesSubscribe, - }, deviceXMessageGrants); - - expect(subscribeGrant?.message.recordId).to.equal(messageGrants.subscribe.recordId); - - const invalidGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.RecordsQuery, - }, deviceXMessageGrants); - - expect(invalidGrant).to.be.undefined; - }); - - it('Messages with protocol', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const protocol = 'http://example.com/protocol'; - const otherProtocol = 'http://example.com/other-protocol'; - - const protocolMessageGrants = await GrantsUtil.createMessagesGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol - }); - - const otherProtocolMessageGrants = await GrantsUtil.createMessagesGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol : otherProtocol - }); - - const deviceXMessageGrants = [ - protocolMessageGrants.query, - protocolMessageGrants.read, - protocolMessageGrants.subscribe, - otherProtocolMessageGrants.query, - otherProtocolMessageGrants.read, - otherProtocolMessageGrants.subscribe - ]; - - const queryGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesQuery, - protocol - }, deviceXMessageGrants); - - expect(queryGrant?.message.recordId).to.equal(protocolMessageGrants.query.recordId); - - const readGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesRead, - protocol - }, deviceXMessageGrants); - - expect(readGrant?.message.recordId).to.equal(protocolMessageGrants.read.recordId); - - const subscribeGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType: DwnInterface.MessagesSubscribe, - protocol - }, deviceXMessageGrants); - - expect(subscribeGrant?.message.recordId).to.equal(protocolMessageGrants.subscribe.recordId); - - const invalidGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.MessagesQuery, - protocol : 'http://example.com/unknown-protocol' - }, deviceXMessageGrants); - - expect(invalidGrant).to.be.undefined; - - const otherProtocolQueryGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.MessagesQuery, - protocol : otherProtocol - }, deviceXMessageGrants); - - expect(otherProtocolQueryGrant?.message.recordId).to.equal(otherProtocolMessageGrants.query.recordId); - }); - - it('Records', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const protocol1 = 'http://example.com/protocol'; - const protocol2 = 'http://example.com/other-protocol'; - - const protocol1Grants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol : protocol1, - }); - - const otherProtocolGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol : protocol2, - }); - - const deviceXRecordGrants = [ - protocol1Grants.write, - protocol1Grants.read, - protocol1Grants.delete, - protocol1Grants.query, - protocol1Grants.subscribe, - otherProtocolGrants.write, - otherProtocolGrants.read, - otherProtocolGrants.delete, - otherProtocolGrants.query, - otherProtocolGrants.subscribe - ]; - - const writeGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol1 - }, deviceXRecordGrants); - - expect(writeGrant?.message.recordId).to.equal(protocol1Grants.write.recordId); - - const readGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsRead, - protocol : protocol1 - }, deviceXRecordGrants); - - expect(readGrant?.message.recordId).to.equal(protocol1Grants.read.recordId); - - const deleteGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsDelete, - protocol : protocol1 - }, deviceXRecordGrants); - - expect(deleteGrant?.message.recordId).to.equal(protocol1Grants.delete.recordId); - - const queryGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsQuery, - protocol : protocol1 - }, deviceXRecordGrants); - - expect(queryGrant?.message.recordId).to.equal(protocol1Grants.query.recordId); - - const subscribeGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsSubscribe, - protocol : protocol1 - }, deviceXRecordGrants); - - expect(subscribeGrant?.message.recordId).to.equal(protocol1Grants.subscribe.recordId); - - const queryGrantOtherProtocol = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsQuery, - protocol : protocol2 - }, deviceXRecordGrants); - - expect(queryGrantOtherProtocol?.message.recordId).to.equal(otherProtocolGrants.query.recordId); - - // unknown protocol - const invalidGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsQuery, - protocol : 'http://example.com/unknown-protocol' - }, deviceXRecordGrants); - - expect(invalidGrant).to.be.undefined; - }); - - it('Records with protocolPath', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const protocol = 'http://example.com/protocol'; - - const fooGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol, - protocolPath : 'foo' - }); - - const barGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol, - protocolPath : 'foo/bar' - }); - - const protocolGrants = [ - fooGrants.write, - fooGrants.read, - fooGrants.delete, - fooGrants.query, - fooGrants.subscribe, - barGrants.write, - barGrants.read, - barGrants.delete, - barGrants.query, - barGrants.subscribe - ]; - - const writeFooGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol, - protocolPath : 'foo' - }, protocolGrants); - - expect(writeFooGrant?.message.recordId).to.equal(fooGrants.write.recordId); - - const readFooGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsRead, - protocol : protocol, - protocolPath : 'foo' - }, protocolGrants); - - expect(readFooGrant?.message.recordId).to.equal(fooGrants.read.recordId); - - const deleteFooGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsDelete, - protocol : protocol, - protocolPath : 'foo' - }, protocolGrants); - - expect(deleteFooGrant?.message.recordId).to.equal(fooGrants.delete.recordId); - - const queryGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsQuery, - protocol : protocol, - protocolPath : 'foo' - }, protocolGrants); - - expect(queryGrant?.message.recordId).to.equal(fooGrants.query.recordId); - - const subscribeGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsSubscribe, - protocol : protocol, - protocolPath : 'foo' - }, protocolGrants); - - expect(subscribeGrant?.message.recordId).to.equal(fooGrants.subscribe.recordId); - - const writeBarGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol, - protocolPath : 'foo/bar' - }, protocolGrants); - - expect(writeBarGrant?.message.recordId).to.equal(barGrants.write.recordId); - - const noMatchGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol, - protocolPath : 'bar' - }, protocolGrants); - - expect(noMatchGrant).to.be.undefined; - }); - - it('Records with contextId', async () => { - const aliceDeviceX = await appAgent.agent.identity.create({ - store : true, - metadata : { name: 'Alice Device X' }, - didMethod : 'jwk' - }); - - const protocol = 'http://example.com/protocol'; - - const abcGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol, - contextId : 'abc' - }); - - const defGrants = await GrantsUtil.createRecordsGrants({ - grantorAgent : aliceAgent.agent, - granteeAgent : appAgent.agent, - grantor : alice.did.uri, - grantee : aliceDeviceX.did.uri, - protocol, - contextId : 'def/ghi' - }); - - const contextGrants = [ - abcGrants.write, - abcGrants.read, - abcGrants.delete, - abcGrants.query, - abcGrants.subscribe, - defGrants.write, - defGrants.read, - defGrants.delete, - defGrants.query, - defGrants.subscribe - ]; - - const writeFooGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol, - contextId : 'abc' - }, contextGrants); - - expect(writeFooGrant?.message.recordId).to.equal(abcGrants.write.recordId); - - const writeBarGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol, - contextId : 'def/ghi' - }, contextGrants); - - expect(writeBarGrant?.message.recordId).to.equal(defGrants.write.recordId); - - const invalidGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol, - contextId : 'def' - }, contextGrants); - - expect(invalidGrant).to.be.undefined; - - const withoutContextGrant = await DwnPermissionsUtil.matchGrantFromArray(alice.did.uri, aliceDeviceX.did.uri, { - messageType : DwnInterface.RecordsWrite, - protocol : protocol - }, contextGrants); - - expect(withoutContextGrant).to.be.undefined; - }); - }); -}); \ No newline at end of file diff --git a/packages/agent/tests/permissions-api.spec.ts b/packages/agent/tests/permissions-api.spec.ts new file mode 100644 index 000000000..0514fb720 --- /dev/null +++ b/packages/agent/tests/permissions-api.spec.ts @@ -0,0 +1,1223 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { AgentPermissionsApi } from '../src/permissions-api.js'; +import { PlatformAgentTestHarness } from '../src/test-harness.js'; +import { TestAgent } from './utils/test-agent.js'; +import { BearerDid } from '@web5/dids'; + +import { testDwnUrl } from './utils/test-config.js'; +import { DwnInterfaceName, DwnMethodName, Time } from '@tbd54566975/dwn-sdk-js'; +import { DwnInterface, DwnPermissionGrant, DwnPermissionScope, Web5PlatformAgent } from '../src/index.js'; + +let testDwnUrls: string[] = [testDwnUrl]; + +describe('AgentPermissionsApi', () => { + let testHarness: PlatformAgentTestHarness; + let aliceDid: BearerDid; + + before(async () => { + testHarness = await PlatformAgentTestHarness.setup({ + agentClass : TestAgent, + agentStores : 'dwn' + }); + }); + + after(async () => { + sinon.restore(); + await testHarness.clearStorage(); + await testHarness.closeStorage(); + }); + + beforeEach(async () => { + sinon.restore(); + await testHarness.clearStorage(); + await testHarness.createAgentDid(); + + // Create an "alice" Identity to author the DWN messages. + const alice = await testHarness.createIdentity({ name: 'Alice', testDwnUrls }); + await testHarness.agent.identity.manage({ portableIdentity: await alice.export() }); + aliceDid = alice.did; + }); + + describe('get agent', () => { + it(`returns the 'agent' instance property`, async () => { + // we are only mocking + const permissionsApi = new AgentPermissionsApi({ agent: testHarness.agent }); + const agent = permissionsApi.agent; + expect(agent).to.exist; + expect(agent.agentDid).to.equal(testHarness.agent.agentDid); + }); + + it(`throws an error if the 'agent' instance property is undefined`, () => { + const permissionsApi = new AgentPermissionsApi(); + expect(() => + permissionsApi.agent + ).to.throw(Error, 'AgentPermissionsApi: Agent is not set'); + }); + }); + + describe('fetchGrants', () => { + it('from remote', async () => { + // spy on the processDwnRequest method + const processDwnRequestSpy = sinon.spy(testHarness.agent, 'processDwnRequest'); + // mock the sendDwnRequest method to return a 200 response + const sendDwnRequestStub = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ messageCid: '', reply: { entries: [], status: { code: 200, detail: 'OK'} }}); + + // fetch permission grants + await testHarness.agent.permissions.fetchGrants({ + author : aliceDid.uri, + target : aliceDid.uri, + remote : true + }); + + // expect the processDwnRequest method to not have been called + expect(processDwnRequestSpy.called).to.be.false; + + // expect the sendDwnRequest method to have been called + expect(sendDwnRequestStub.called).to.be.true; + }); + + it('filter by protocol', async () => { + // create a grant for permission-1 + const protocol1Grant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, + grantedTo : aliceDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-1' + } + }); + + // create a grant for permission-2 + const protocol2Grant = await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, + grantedTo : aliceDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-2' + } + }); + + // fetch permission grants + const protocol1Grants = await testHarness.agent.permissions.fetchGrants({ + author : aliceDid.uri, + target : aliceDid.uri, + protocol : 'http://example.com/protocol-1' + }); + expect(protocol1Grants.length).to.equal(1); + expect(protocol1Grants[0].grant.id).to.equal(protocol1Grant.grant.id); + + const protocol2Grants = await testHarness.agent.permissions.fetchGrants({ + author : aliceDid.uri, + target : aliceDid.uri, + protocol : 'http://example.com/protocol-2' + }); + expect(protocol2Grants.length).to.equal(1); + expect(protocol2Grants[0].grant.id).to.equal(protocol2Grant.grant.id); + }); + + it('throws if the query returns anything other than 200', async () => { + // stub the processDwnRequest method to return a 400 error + sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'Bad Request'} }}); + + // fetch permission requests + try { + await testHarness.agent.permissions.fetchGrants({ + author : aliceDid.uri, + target : aliceDid.uri, + }); + } catch(error: any) { + expect(error.message).to.equal('PermissionsApi: Failed to fetch grants: Bad Request'); + } + }); + }); + + describe('fetchRequests', () => { + it('from remote', async () => { + // spy on the processDwnRequest method + const processDwnRequestSpy = sinon.spy(testHarness.agent, 'processDwnRequest'); + // mock the sendDwnRequest method to return a 200 response + const sendDwnRequestStub = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ messageCid: '', reply: { entries: [], status: { code: 200, detail: 'OK'} }}); + + // fetch permission grants + await testHarness.agent.permissions.fetchRequests({ + author : aliceDid.uri, + target : aliceDid.uri, + remote : true + }); + + // expect the processDwnRequest method to not have been called + expect(processDwnRequestSpy.called).to.be.false; + + // expect the sendDwnRequest method to have been called + expect(sendDwnRequestStub.called).to.be.true; + }); + + it('filter by protocol', async () => { + // create a request for permission-1 + const protocol1Request = await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-1' + } + }); + + // create a request for permission-2 + const protocol2Request = await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-2' + } + }); + + // fetch permission grants + const protocol1Requests = await testHarness.agent.permissions.fetchRequests({ + author : aliceDid.uri, + target : aliceDid.uri, + protocol : 'http://example.com/protocol-1' + }); + expect(protocol1Requests.length).to.equal(1); + expect(protocol1Requests[0].request.id).to.equal(protocol1Request.request.id); + + const protocol2Requests = await testHarness.agent.permissions.fetchRequests({ + author : aliceDid.uri, + target : aliceDid.uri, + protocol : 'http://example.com/protocol-2' + }); + expect(protocol2Requests.length).to.equal(1); + expect(protocol2Requests[0].request.id).to.equal(protocol2Request.request.id); + }); + + it('throws if the query returns anything other than 200', async () => { + // stub the processDwnRequest method to return a 400 error + sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'Bad Request'} }}); + + // fetch permission requests + try { + await testHarness.agent.permissions.fetchRequests({ + author : aliceDid.uri, + target : aliceDid.uri, + }); + } catch(error: any) { + expect(error.message).to.equal('PermissionsApi: Failed to fetch requests: Bad Request'); + } + }); + }); + + describe('isGrantRevoked', () => { + it('from remote', async () => { + // spy on the processDwnRequest method + const processDwnRequestSpy = sinon.spy(testHarness.agent, 'processDwnRequest'); + // mock the sendDwnRequest method to return a 200 response + const sendDwnRequestStub = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK'} }}); + + // fetch permission grants + await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : 'grant-record-id', + remote : true + }); + + // expect the processDwnRequest method to not have been called + expect(processDwnRequestSpy.called).to.be.false; + + // expect the sendDwnRequest method to have been called + expect(sendDwnRequestStub.called).to.be.true; + }); + + it('throws if the request was bad', async () => { + // stub the processDwnRequest method to return a 400 error + sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'Bad Request'} }}); + + // create a permission request + try { + await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : 'grant-record-id' + }); + } catch(error: any) { + expect(error.message).to.equal('PermissionsApi: Failed to check if grant is revoked: Bad Request'); + } + }); + + it('returns revocation status', async () => { + // scenario: create a grant for deviceX, revoke the grant, confirm the grant is revoked + + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + // create a grant for deviceX + const deviceXGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : aliceDeviceX.did.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // check if the grant is revoked + let isRevoked = await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : deviceXGrant.grant.id + }); + expect(isRevoked).to.equal(false); + + // create a revocation for the grant + await testHarness.agent.permissions.createRevocation({ + author : aliceDid.uri, + store : true, + grant : deviceXGrant.grant, + }); + + // check if the grant is revoked again, should be true + isRevoked = await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : deviceXGrant.grant.id + }); + expect(isRevoked).to.equal(true); + }); + }); + + describe('createGrant', () => { + it('throws if the grant was not created', async () => { + // stub the processDwnRequest method to return a 400 error + sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'Bad Request'} }}); + + // create a permission request + try { + await testHarness.agent.permissions.createGrant({ + author : aliceDid.uri, + grantedTo : 'did:example:deviceX', + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + scope : {} as DwnPermissionScope, + }); + } catch(error: any) { + expect(error.message).to.equal('PermissionsApi: Failed to create grant: Bad Request'); + } + }); + + it('creates and stores a grant', async () => { + // scenario: create a grant for deviceX, confirm the grant exists + + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : false, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + + // create a grant for deviceX + const deviceXGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : aliceDeviceX.did.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + const grants = await testHarness.agent.permissions.fetchGrants({ + author : aliceDid.uri, + target : aliceDid.uri, + }); + + // expect to have the 1 grant created for deviceX + expect(grants.length).to.equal(1); + expect(grants[0].message.recordId).to.equal(deviceXGrant.message.recordId); + }); + + it('creates a grant without storing it', async () => { + // scenario: create a grant for deviceX, confirm the grant does not exist + + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : false, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + // create a grant for deviceX store is set to false by default + const deviceXGrant = 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' + } + }); + + const grantDataObject = { ...deviceXGrant.grant }; + const parsedGrant = await DwnPermissionGrant.parse(deviceXGrant.message); + + expect(grantDataObject).to.deep.equal(parsedGrant); + }); + }); + + describe('createRevocation', () => { + it('throws if the revocation was not created', async () => { + // stub the processDwnRequest method to return a 400 error + sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'Bad Request'} }}); + + // create a permission request + try { + await testHarness.agent.permissions.createRevocation({ + author : aliceDid.uri, + store : true, + grant : { + scope: {} + } as DwnPermissionGrant, + }); + } catch(error: any) { + expect(error.message).to.equal('PermissionsApi: Failed to create revocation: Bad Request'); + } + + }); + + it('creates and stores a grant revocation', async () => { + // scenario: create a grant for deviceX, revoke the grant, confirm the grant is revoked + + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + // create a grant for deviceX + const deviceXGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : aliceDeviceX.did.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // parse the grant + const writeGrant = await DwnPermissionGrant.parse(deviceXGrant.message); + + // check if the grant is revoked + let isRevoked = await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : deviceXGrant.grant.id + }); + expect(isRevoked).to.equal(false); + + // create a revocation for the grant + await testHarness.agent.permissions.createRevocation({ + author : aliceDid.uri, + store : true, + grant : writeGrant, + }); + + // check if the grant is revoked again, should be true + isRevoked = await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : deviceXGrant.grant.id + }); + expect(isRevoked).to.equal(true); + }); + + it('creates a grant revocation without storing it', async () => { + // scenario: create a grant for deviceX, revoke the grant, confirm the grant is revoked + + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + // create a grant for deviceX + const deviceXGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : aliceDeviceX.did.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // parse the grant + const writeGrant = await DwnPermissionGrant.parse(deviceXGrant.message); + + // check if the grant is revoked + let isRevoked = await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : deviceXGrant.grant.id + }); + expect(isRevoked).to.equal(false); + + // create a revocation for the grant without storing it + await testHarness.agent.permissions.createRevocation({ + author : aliceDid.uri, + grant : writeGrant, + }); + + // check if the grant is revoked again, should be true + isRevoked = await testHarness.agent.permissions.isGrantRevoked({ + author : aliceDid.uri, + target : aliceDid.uri, + grantRecordId : deviceXGrant.grant.id + }); + expect(isRevoked).to.equal(false); + }); + }); + + describe('createRequest', () => { + it('throws if the request was not created', async () => { + // stub the processDwnRequest method to return a 400 error + sinon.stub(testHarness.agent, 'processDwnRequest').resolves({ messageCid: '', reply: { status: { code: 400, detail: 'Bad Request'} }}); + + // create a permission request + try { + await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + } catch(error: any) { + expect(error.message).to.equal('PermissionsApi: Failed to create request: Bad Request'); + } + + }); + + it('creates a permission request and stores it', async () => { + // scenario: create a permission request confirm the request exists + + // create a permission request + const deviceXRequest = await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // query for the request + const fetchedRequests = await testHarness.agent.permissions.fetchRequests({ + author : aliceDid.uri, + target : aliceDid.uri, + }); + + // expect to have the 1 request created + expect(fetchedRequests.length).to.equal(1); + expect(fetchedRequests[0].request.id).to.equal(deviceXRequest.message.recordId); + }); + + it('creates a permission request without storing it', async () => { + // scenario: create a permission request confirm the request does not exist + + // create a permission request store is set to false by default + await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // query for the request + const fetchedRequests = await testHarness.agent.permissions.fetchRequests({ + author : aliceDid.uri, + target : aliceDid.uri, + }); + + // expect to have no requests + expect(fetchedRequests.length).to.equal(0); + }); + }); + + describe('matchGrantFromArray', () => { + + const createRecordGrants = async ({ grantee, grantor, grantorAgent, protocol, protocolPath, contextId }:{ + grantorAgent: Web5PlatformAgent; + granteeAgent: Web5PlatformAgent; + grantor: string; + grantee: string; + protocol: string; + protocolPath?: string; + contextId?: string; + }) => { + const recordsWriteGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol, + protocolPath, + contextId + } + }); + + const recordsReadGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Read, + protocol, + protocolPath, + contextId + } + }); + + const recordsDeleteGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Delete, + protocol, + protocolPath, + contextId + } + }); + + const recordsQueryGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Query, + protocol, + protocolPath, + contextId + } + }); + + const recordsSubscribeGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Subscribe, + protocol, + protocolPath, + contextId + } + }); + + return { + write : recordsWriteGrant, + read : recordsReadGrant, + delete : recordsDeleteGrant, + query : recordsQueryGrant, + subscribe : recordsSubscribeGrant + }; + }; + + const createMessageGrants = async ({ grantee, grantor, grantorAgent, protocol }:{ + grantorAgent: Web5PlatformAgent; + granteeAgent: Web5PlatformAgent; + grantor: string; + grantee: string; + protocol?: string; + }) => { + + const messagesReadGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read, + protocol + } + }); + + const messagesQueryGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Query, + protocol + } + }); + + const messagesSubscribeGrant = await grantorAgent.permissions.createGrant({ + author : grantor, + grantedTo : grantee, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + store : true, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Subscribe, + protocol + } + }); + + return { + read : messagesReadGrant, + query : messagesQueryGrant, + subscribe : messagesSubscribeGrant + }; + }; + + it('does not match a grant with a different grantee or grantor', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const aliceDeviceY = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device Y' }, + didMethod : 'jwk' + }); + + const protocol = 'http://example.com/protocol'; + + + const deviceXRecordGrantsFromAlice = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol + }); + + const deviceXRecordGrantsFromAliceArray = [ + deviceXRecordGrantsFromAlice.write, + deviceXRecordGrantsFromAlice.read, + deviceXRecordGrantsFromAlice.delete, + deviceXRecordGrantsFromAlice.query, + deviceXRecordGrantsFromAlice.subscribe + ]; + + // attempt to match a grant with a different grantee, aliceDeviceY + const notFoundGrantee = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceY.did.uri, { + messageType: DwnInterface.RecordsWrite, + protocol + }, deviceXRecordGrantsFromAliceArray); + + expect(notFoundGrantee).to.be.undefined; + + const deviceYRecordGrantsFromDeviceX = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDeviceX.did.uri, + grantee : aliceDeviceY.did.uri, + protocol + }); + + const deviceYRecordGrantsFromDeviceXArray = [ + deviceYRecordGrantsFromDeviceX.write, + deviceYRecordGrantsFromDeviceX.read, + deviceYRecordGrantsFromDeviceX.delete, + deviceYRecordGrantsFromDeviceX.query, + deviceYRecordGrantsFromDeviceX.subscribe + ]; + + const notFoundGrantor = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceY.did.uri, { + messageType: DwnInterface.RecordsWrite, + protocol + }, deviceYRecordGrantsFromDeviceXArray); + + expect(notFoundGrantor).to.be.undefined; + }); + + it('matches delegated grants if specified', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const messagesGrants = await createMessageGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + }); + + const aliceDeviceXMessageGrants = [ + messagesGrants.query, + messagesGrants.read, + messagesGrants.subscribe + ]; + + // control: match a grant without specifying delegated + const queryGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesQuery, + }, aliceDeviceXMessageGrants); + + expect(queryGrant?.message.recordId).to.equal(messagesGrants.query.message.recordId); + + // attempt to match non-delegated grant with delegated set to true + const notFoundDelegated = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesQuery, + }, aliceDeviceXMessageGrants, true); + + expect(notFoundDelegated).to.be.undefined; + + // create delegated record grants + const protocol = 'http://example.com/protocol'; + const recordsGrants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol + }); + + const deviceXRecordGrants = [ + recordsGrants.write, + recordsGrants.read, + recordsGrants.delete, + recordsGrants.query, + recordsGrants.subscribe + ]; + + // match a delegated grant + const writeGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.RecordsWrite, + protocol + }, deviceXRecordGrants, true); + + expect(writeGrant?.message.recordId).to.equal(recordsGrants.write.message.recordId); + }); + + it('Messages', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const messageGrants = await createMessageGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri + }); + + const deviceXMessageGrants = [ + messageGrants.query, + messageGrants.read, + messageGrants.subscribe + ]; + + const queryGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesQuery, + }, deviceXMessageGrants); + + expect(queryGrant?.message.recordId).to.equal(messageGrants.query.message.recordId); + + const readGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesRead, + }, deviceXMessageGrants); + + expect(readGrant?.message.recordId).to.equal(messageGrants.read.message.recordId); + + const subscribeGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesSubscribe, + }, deviceXMessageGrants); + + expect(subscribeGrant?.message.recordId).to.equal(messageGrants.subscribe.message.recordId); + + const invalidGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.RecordsQuery, + }, deviceXMessageGrants); + + expect(invalidGrant).to.be.undefined; + }); + + it('Messages with protocol', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const protocol = 'http://example.com/protocol'; + const otherProtocol = 'http://example.com/other-protocol'; + + const protocolMessageGrants = await createMessageGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol + }); + + const otherProtocolMessageGrants = await createMessageGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol : otherProtocol + }); + + const deviceXMessageGrants = [ + protocolMessageGrants.query, + protocolMessageGrants.read, + protocolMessageGrants.subscribe, + otherProtocolMessageGrants.query, + otherProtocolMessageGrants.read, + otherProtocolMessageGrants.subscribe + ]; + + const queryGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesQuery, + protocol + }, deviceXMessageGrants); + + expect(queryGrant?.message.recordId).to.equal(protocolMessageGrants.query.message.recordId); + + const readGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesRead, + protocol + }, deviceXMessageGrants); + + expect(readGrant?.message.recordId).to.equal(protocolMessageGrants.read.message.recordId); + + const subscribeGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType: DwnInterface.MessagesSubscribe, + protocol + }, deviceXMessageGrants); + + expect(subscribeGrant?.message.recordId).to.equal(protocolMessageGrants.subscribe.message.recordId); + + const invalidGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.MessagesQuery, + protocol : 'http://example.com/unknown-protocol' + }, deviceXMessageGrants); + + expect(invalidGrant).to.be.undefined; + + const otherProtocolQueryGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.MessagesQuery, + protocol : otherProtocol + }, deviceXMessageGrants); + + expect(otherProtocolQueryGrant?.message.recordId).to.equal(otherProtocolMessageGrants.query.message.recordId); + }); + + it('Records', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const protocol1 = 'http://example.com/protocol'; + const protocol2 = 'http://example.com/other-protocol'; + + const protocol1Grants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol : protocol1, + }); + + const otherProtocolGrants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol : protocol2, + }); + + const deviceXRecordGrants = [ + protocol1Grants.write, + protocol1Grants.read, + protocol1Grants.delete, + protocol1Grants.query, + protocol1Grants.subscribe, + otherProtocolGrants.write, + otherProtocolGrants.read, + otherProtocolGrants.delete, + otherProtocolGrants.query, + otherProtocolGrants.subscribe + ]; + + const writeGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol1 + }, deviceXRecordGrants); + + expect(writeGrant?.message.recordId).to.equal(protocol1Grants.write.message.recordId); + + const readGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsRead, + protocol : protocol1 + }, deviceXRecordGrants); + + expect(readGrant?.message.recordId).to.equal(protocol1Grants.read.message.recordId); + + const deleteGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsDelete, + protocol : protocol1 + }, deviceXRecordGrants); + + expect(deleteGrant?.message.recordId).to.equal(protocol1Grants.delete.message.recordId); + + const queryGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsQuery, + protocol : protocol1 + }, deviceXRecordGrants); + + expect(queryGrant?.message.recordId).to.equal(protocol1Grants.query.message.recordId); + + const subscribeGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsSubscribe, + protocol : protocol1 + }, deviceXRecordGrants); + + expect(subscribeGrant?.message.recordId).to.equal(protocol1Grants.subscribe.message.recordId); + + const queryGrantOtherProtocol = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsQuery, + protocol : protocol2 + }, deviceXRecordGrants); + + expect(queryGrantOtherProtocol?.message.recordId).to.equal(otherProtocolGrants.query.message.recordId); + + // unknown protocol + const invalidGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsQuery, + protocol : 'http://example.com/unknown-protocol' + }, deviceXRecordGrants); + + expect(invalidGrant).to.be.undefined; + }); + + it('Records with protocolPath', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const protocol = 'http://example.com/protocol'; + + const fooGrants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol, + protocolPath : 'foo' + }); + + const barGrants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol, + protocolPath : 'foo/bar' + }); + + const protocolGrants = [ + fooGrants.write, + fooGrants.read, + fooGrants.delete, + fooGrants.query, + fooGrants.subscribe, + barGrants.write, + barGrants.read, + barGrants.delete, + barGrants.query, + barGrants.subscribe + ]; + + const writeFooGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol, + protocolPath : 'foo' + }, protocolGrants); + + expect(writeFooGrant?.message.recordId).to.equal(fooGrants.write.message.recordId); + + const readFooGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsRead, + protocol : protocol, + protocolPath : 'foo' + }, protocolGrants); + + expect(readFooGrant?.message.recordId).to.equal(fooGrants.read.message.recordId); + + const deleteFooGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsDelete, + protocol : protocol, + protocolPath : 'foo' + }, protocolGrants); + + expect(deleteFooGrant?.message.recordId).to.equal(fooGrants.delete.message.recordId); + + const queryGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsQuery, + protocol : protocol, + protocolPath : 'foo' + }, protocolGrants); + + expect(queryGrant?.message.recordId).to.equal(fooGrants.query.message.recordId); + + const subscribeGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsSubscribe, + protocol : protocol, + protocolPath : 'foo' + }, protocolGrants); + + expect(subscribeGrant?.message.recordId).to.equal(fooGrants.subscribe.message.recordId); + + const writeBarGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol, + protocolPath : 'foo/bar' + }, protocolGrants); + + expect(writeBarGrant?.message.recordId).to.equal(barGrants.write.message.recordId); + + const noMatchGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol, + protocolPath : 'bar' + }, protocolGrants); + + expect(noMatchGrant).to.be.undefined; + }); + + it('Records with contextId', async () => { + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); + + const protocol = 'http://example.com/protocol'; + + const abcGrants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol, + contextId : 'abc' + }); + + const defGrants = await createRecordGrants({ + grantorAgent : testHarness.agent as Web5PlatformAgent, + granteeAgent : testHarness.agent as Web5PlatformAgent, + grantor : aliceDid.uri, + grantee : aliceDeviceX.did.uri, + protocol, + contextId : 'def/ghi' + }); + + const contextGrants = [ + abcGrants.write, + abcGrants.read, + abcGrants.delete, + abcGrants.query, + abcGrants.subscribe, + defGrants.write, + defGrants.read, + defGrants.delete, + defGrants.query, + defGrants.subscribe + ]; + + const writeFooGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol, + contextId : 'abc' + }, contextGrants); + + expect(writeFooGrant?.message.recordId).to.equal(abcGrants.write.message.recordId); + + const writeBarGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol, + contextId : 'def/ghi' + }, contextGrants); + + expect(writeBarGrant?.message.recordId).to.equal(defGrants.write.message.recordId); + + const invalidGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol, + contextId : 'def' + }, contextGrants); + + expect(invalidGrant).to.be.undefined; + + const withoutContextGrant = await AgentPermissionsApi.matchGrantFromArray(aliceDid.uri, aliceDeviceX.did.uri, { + messageType : DwnInterface.RecordsWrite, + protocol : protocol + }, contextGrants); + + expect(withoutContextGrant).to.be.undefined; + }); + }); +}); \ No newline at end of file diff --git a/packages/agent/tests/utils/grants.ts b/packages/agent/tests/utils/grants.ts deleted file mode 100644 index 41ecea0a8..000000000 --- a/packages/agent/tests/utils/grants.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { DataEncodedRecordsWriteMessage, DwnInterfaceName, DwnMethodName, Time } from '@tbd54566975/dwn-sdk-js'; -import { DwnInterface, Web5PlatformAgent } from '../../src/index.js'; - -export type MessagesGrants = { - query: DataEncodedRecordsWriteMessage; - read: DataEncodedRecordsWriteMessage; - subscribe: DataEncodedRecordsWriteMessage; -} - -export type RecordsGrants = { - write: DataEncodedRecordsWriteMessage; - delete: DataEncodedRecordsWriteMessage; - read: DataEncodedRecordsWriteMessage; - query: DataEncodedRecordsWriteMessage; - subscribe: DataEncodedRecordsWriteMessage; -} - -export class GrantsUtil { - - /** - * Creates a full set of `Records` interface delegated grants from `grantor` to `grantee`. - * The grants are processed and stored by the `granteeAgent` so that they are available when the grantee attempts to use them. - */ - static async createRecordsGrants({ grantorAgent, grantor, granteeAgent, grantee, protocol, contextId, protocolPath }: { - grantorAgent: Web5PlatformAgent, - grantor: string; - granteeAgent: Web5PlatformAgent, - grantee: string; - protocol: string; - contextId?: string; - protocolPath?: string - }): Promise { - - // RecordsWrite grant - const recordsWriteGrant = await grantorAgent.dwn.createGrant({ - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Write, - protocolPath, - contextId, - protocol, - } - }); - - // write the grant to the grantee's DWN - const recordsWriteGrantReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrant.recordsWrite.message, - dataStream : new Blob([ recordsWriteGrant.permissionGrantBytes ]), - signAsOwner : true - }); - - if (recordsWriteGrantReply.reply.status.code !== 202) { - throw new Error(`Failed to write RecordsWrite grant: ${recordsWriteGrantReply.reply.status.detail}`); - } - - // RecordsDelete grant - const recordsDeleteGrant = await grantorAgent.dwn.createGrant({ - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Delete, - protocolPath, - contextId, - protocol, - } - }); - - const recordsDeleteGrantReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsDeleteGrant.recordsWrite.message, - dataStream : new Blob([ recordsDeleteGrant.permissionGrantBytes ]), - signAsOwner : true - }); - - if (recordsDeleteGrantReply.reply.status.code !== 202) { - throw new Error(`Failed to write RecordsDelete grant: ${recordsDeleteGrantReply.reply.status.detail}`); - } - - // RecordsRead grant - const recordsReadGrant = await grantorAgent.dwn.createGrant({ - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Read, - protocolPath, - contextId, - protocol, - } - }); - - const recordsReadGrantReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsReadGrant.recordsWrite.message, - dataStream : new Blob([ recordsReadGrant.permissionGrantBytes ]), - signAsOwner : true - }); - - if (recordsReadGrantReply.reply.status.code !== 202) { - throw new Error(`Failed to write RecordsRead grant: ${recordsReadGrantReply.reply.status.detail}`); - } - - // RecordsQuery grant - const recordsQueryGrant = await grantorAgent.dwn.createGrant({ - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Query, - protocol, - protocolPath, - contextId, - } - }); - - const recordsQueryGrantReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsQueryGrant.recordsWrite.message, - dataStream : new Blob([ recordsQueryGrant.permissionGrantBytes ]), - signAsOwner : true - }); - - if (recordsQueryGrantReply.reply.status.code !== 202) { - throw new Error(`Failed to write RecordsQuery grant: ${recordsQueryGrantReply.reply.status.detail}`); - } - - // RecordsSubscribe grant - const recordsSubscribeGrant = await grantorAgent.dwn.createGrant({ - delegated : true, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Subscribe, - protocolPath, - contextId, - protocol, - } - }); - - const recordsSubscribeGrantReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsSubscribeGrant.recordsWrite.message, - dataStream : new Blob([ recordsSubscribeGrant.permissionGrantBytes ]), - signAsOwner : true - }); - - if (recordsSubscribeGrantReply.reply.status.code !== 202) { - throw new Error(`Failed to write RecordsSubscribe grant: ${recordsSubscribeGrantReply.reply.status.detail}`); - } - - return { - write : recordsWriteGrant.dataEncodedMessage, - delete : recordsDeleteGrant.dataEncodedMessage, - read : recordsReadGrant.dataEncodedMessage, - query : recordsQueryGrant.dataEncodedMessage, - subscribe : recordsSubscribeGrant.dataEncodedMessage, - }; - }; - - /** - * Creates a full set of `Messages` interface permission grants from `grantor` to `grantee`. - */ - static async createMessagesGrants ({ grantorAgent, grantor, granteeAgent, grantee, protocol }: { - grantorAgent: Web5PlatformAgent, - grantor: string; - granteeAgent: Web5PlatformAgent, - grantee: string; - protocol?: string; - }): Promise { - // MessagesQuery grant - const messagesQueryGrant = await grantorAgent.dwn.createGrant({ - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - protocol, - } - }); - - const messagesQueryReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrant.recordsWrite.message, - dataStream : new Blob([ messagesQueryGrant.permissionGrantBytes ]), - signAsOwner : true, - }); - - if (messagesQueryReply.reply.status.code !== 202) { - throw new Error(`Failed to write MessagesQuery grant: ${messagesQueryReply.reply.status.detail}`); - } - - // MessagesRead - const messagesReadGrant = await grantorAgent.dwn.createGrant({ - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Read, - protocol, - } - }); - - const messagesReadReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesReadGrant.recordsWrite.message, - dataStream : new Blob([ messagesReadGrant.permissionGrantBytes ]), - signAsOwner : true, - }); - - if (messagesReadReply.reply.status.code !== 202) { - throw new Error(`Failed to write MessagesRead grant: ${messagesReadReply.reply.status.detail}`); - } - - // MessagesSubscribe - const messagesSubscribeGrant = await grantorAgent.dwn.createGrant({ - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - grantedFrom : grantor, - grantedTo : grantee, - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Subscribe, - protocol, - } - }); - - const messagesSubscribeReply = await granteeAgent.dwn.processRequest({ - author : grantee, - target : grantee, - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesSubscribeGrant.recordsWrite.message, - dataStream : new Blob([ messagesSubscribeGrant.permissionGrantBytes ]), - signAsOwner : true, - }); - - if (messagesSubscribeReply.reply.status.code !== 202) { - throw new Error(`Failed to write MessagesSubscribe grant: ${messagesSubscribeReply.reply.status.detail}`); - } - - - return { - query : messagesQueryGrant.dataEncodedMessage, - read : messagesReadGrant.dataEncodedMessage, - subscribe : messagesSubscribeGrant.dataEncodedMessage, - }; - }; - -} \ No newline at end of file diff --git a/packages/agent/tests/utils/test-agent.ts b/packages/agent/tests/utils/test-agent.ts index 32bf0d61c..234917ec4 100644 --- a/packages/agent/tests/utils/test-agent.ts +++ b/packages/agent/tests/utils/test-agent.ts @@ -18,6 +18,7 @@ import type { AgentIdentityApi } from '../../src/identity-api.js'; import type { AgentDidApi, DidInterface } from '../../src/did-api.js'; import type { AgentKeyManager } from '../../src/types/key-manager.js'; import type { IdentityVault } from '../../src/types/identity-vault.js'; +import { AgentPermissionsApi } from '../../src/permissions-api.js'; type TestAgentParams = { agentVault: IdentityVault; @@ -26,6 +27,7 @@ type TestAgentParams = { dwnApi: AgentDwnApi; identityApi: AgentIdentityApi; keyManager: TKeyManager; + permissionsApi: AgentPermissionsApi; rpcClient: Web5Rpc; syncApi: AgentSyncApi; } @@ -36,6 +38,7 @@ export class TestAgent implements Web5Platf public dwn: AgentDwnApi; public identity: AgentIdentityApi; public keyManager: TKeyManager; + public permissions: AgentPermissionsApi; public rpc: Web5Rpc; public sync: AgentSyncApi; public vault: IdentityVault; @@ -48,6 +51,7 @@ export class TestAgent implements Web5Platf this.dwn = params.dwnApi; this.identity = params.identityApi; this.keyManager = params.keyManager; + this.permissions = params.permissionsApi; this.rpc = params.rpcClient; this.sync = params.syncApi; this.vault = params.agentVault; @@ -57,6 +61,7 @@ export class TestAgent implements Web5Platf this.dwn.agent = this; this.identity.agent = this; this.keyManager.agent = this; + this.permissions.agent = this; this.sync.agent = this; } diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index eb5e44991..c7c45bbc5 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -4,6 +4,13 @@ */ /// +import type { + CreateGrantParams, + CreateRequestParams, + FetchPermissionRequestParams, + FetchPermissionsParams +} from '@web5/agent'; + import { Web5Agent, DwnMessage, @@ -12,15 +19,41 @@ import { DwnResponseStatus, ProcessDwnRequest, DwnPaginationCursor, - DwnDataEncodedRecordsWriteMessage + DwnDataEncodedRecordsWriteMessage, + AgentPermissionsApi } from '@web5/agent'; -import { Convert, isEmptyObject, TtlCache } from '@web5/common'; -import { DwnInterface, getRecordAuthor, DwnPermissionsUtil } from '@web5/agent'; +import { isEmptyObject, TtlCache } from '@web5/common'; +import { DwnInterface, getRecordAuthor } from '@web5/agent'; import { Record } from './record.js'; import { dataToBlob } from './utils.js'; import { Protocol } from './protocol.js'; +import { PermissionGrant } from './permission-grant.js'; +import { PermissionRequest } from './permission-request.js'; + +/** + * Represents the request payload for fetching permission requests from a Decentralized Web Node (DWN). + * + * Optionally, specify a remote DWN target in the `from` property to fetch requests from. + */ +export type FetchRequestsRequest = Omit & { + /** Optional DID specifying the remote target DWN tenant to be queried. */ + from?: string; +}; + +/** + * Represents the request payload for fetching permission grants from a Decentralized Web Node (DWN). + * + * Optionally, specify a remote DWN target in the `from` property to fetch requests from. + * Optionally, specify whether to check if the grant is revoked in the `checkRevoked` property. + */ +export type FetchGrantsRequest = Omit & { + /** Optional DID specifying the remote target DWN tenant to be queried. */ + from?: string; + /** Optionally check if the grant has been revoked. */ + checkRevoked?: boolean; +}; /** * Represents the request payload for configuring a protocol on a Decentralized Web Node (DWN). @@ -234,44 +267,61 @@ export class DwnApi { /** (optional) The DID of the signer when signing with permissions */ private delegateDid?: string; - /** cache for fetching permissions */ - private cachedPermissions: TtlCache = new TtlCache({ ttl: 60 * 1000 }); + /** Holds the instance of {@link AgentPermissionsApi} that helps when dealing with permissions protocol records */ + private permissionsApi: AgentPermissionsApi; + + /** cache for fetching a permission {@link PermissionGrant}, keyed by a specific MessageType and protocol */ + 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.permissionsApi = 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 }:{ + findPermissionGrantForMessage: async ({ messageParams, cached = true }:{ + cached?: boolean; messageParams: { - messageType: T, - protocol: string, + messageType: T; + protocol: string; } - }) : Promise => { + }) : Promise => { if(!this.delegateDid) { throw new Error('AgentDwnApi: Cannot find connected grants without a signer DID'); } - const permissions = await this.grants.fetchConnectedGrants(); + // Currently we only support finding grants based on protocols + // A different approach may be necessary when we introduce `protocolPath` and `contextId` specific impersonation + const cacheKey = [ this.connectedDid, messageParams.messageType, messageParams.protocol ].join('~'); + const cachedGrant = cached ? this.cachedPermissions.get(cacheKey) : undefined; + if (cachedGrant) { + return cachedGrant; + } + + const permissionGrants = await this.permissions.queryGrants({ checkRevoked: true, grantor: this.connectedDid }); + + const grantEntries = permissionGrants.map(grant => ({ message: grant.rawMessage, grant: grant.toJSON() })); // get the delegate grants that match the messageParams and are associated with the connectedDid as the grantor - const delegateGrant = await DwnPermissionsUtil.matchGrantFromArray( + const delegateGrant = await AgentPermissionsApi.matchGrantFromArray( this.connectedDid, this.delegateDid, messageParams, - permissions, + grantEntries, true ); @@ -279,134 +329,112 @@ export class DwnApi { throw new Error(`AgentDwnApi: No permissions found for ${messageParams.messageType}: ${messageParams.protocol}`); } - return delegateGrant.message; - }, + const grant = await PermissionGrant.parse({ connectedDid: this.delegateDid, agent: this.agent, message: delegateGrant.message }); + this.cachedPermissions.set(cacheKey, grant); + return grant; + } + }; + } + /** + * API to interact with Grants + * + * NOTE: This is an EXPERIMENTAL API that will change behavior. + * @beta + */ + get permissions() { + return { /** - * Performs a RecordsQuery for permission grants that match the given parameters. - * - * (optionally) Caches the results for the given parameters to avoid redundant queries. + * Request permission for a specific scope. */ - fetchConnectedGrants: async (cached: boolean = true): 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') - } - } + request: async(request: Omit): Promise => { + const { message } = await this.permissionsApi.createRequest({ + ...request, + author: this.delegateDid ?? this.connectedDid, }); - if (grantsReply.status.code !== 200) { - throw new Error(`AgentDwnApi: Failed to fetch grants: ${grantsReply.status.detail}`); - } + const requestParams = { + connectedDid : this.delegateDid ?? this.connectedDid, + agent : this.agent, + message, + }; - const grants:DwnDataEncodedRecordsWriteMessage[] = []; - for (const entry of grantsReply.entries! as DwnDataEncodedRecordsWriteMessage[]) { - // 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 - continue; - } - grants.push(entry as DwnDataEncodedRecordsWriteMessage); - } + return await PermissionRequest.parse(requestParams); + }, + /** + * Grant permission for a specific scope to a grantee DID. + */ + grant: async(request: Omit): Promise => { + const { message } = await this.permissionsApi.createGrant({ + ...request, + author: this.delegateDid ?? this.connectedDid, + }); - if (cached) { - this.cachedPermissions.set(cacheKey, grants); - } + const grantParams = { + connectedDid : this.delegateDid ?? this.connectedDid, + agent : this.agent, + message, + }; - return grants; + return await PermissionGrant.parse(grantParams); }, - /** - * Check whether a grant is revoked by reading the revocation record for a given grant recordId. + * Query permission requests. You can filter by protocol and specify if you want to query a remote DWN. */ - 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') - } - } + queryRequests: async(request: FetchRequestsRequest= {}): Promise => { + const { from, ...params } = request; + const fetchResponse = await this.permissionsApi.fetchRequests({ + ...params, + author : this.delegateDid ?? this.connectedDid, + target : from ?? this.delegateDid ?? this.connectedDid, + remote : from !== undefined, }); - 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; + const requests: PermissionRequest[] = []; + for (const permission of fetchResponse) { + const requestParams = { + connectedDid : this.delegateDid ?? this.connectedDid, + agent : this.agent, + message : permission.message, + }; + requests.push(await PermissionRequest.parse(requestParams)); } - throw new Error(`AgentDwnApi: Failed to check if grant is revoked: ${revocationReply.status.detail}`); + return requests; }, - /** - * 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. + * Query permission grants. You can filter by grantee, grantor, protocol and specify if you want to query a remote DWN. */ - processConnectedGrantsAsOwner: async (grants: DwnDataEncodedRecordsWriteMessage[]): Promise => { - if(!this.delegateDid) { - throw new Error('AgentDwnApi: Cannot process grants without a signer DID'); - } + queryGrants: async(request: FetchGrantsRequest = {}): Promise => { + const { checkRevoked, from, ...params } = request; + const remote = from !== undefined; + const author = this.delegateDid ?? this.connectedDid; + const target = from ?? this.delegateDid ?? this.connectedDid; + const fetchResponse = await this.permissionsApi.fetchGrants({ + ...params, + author, + target, + remote, + }); - 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 ]) - }); + const grants: PermissionGrant[] = []; + for (const permission of fetchResponse) { + const grantParams = { + connectedDid : this.delegateDid ?? this.connectedDid, + agent : this.agent, + message : permission.message, + }; - 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); - } + if (checkRevoked) { + const grantRecordId = permission.grant.id; + if(await this.permissionsApi.isGrantRevoked({ author, target, grantRecordId, remote })) { + continue; } - - throw new Error(`Failed to process delegated grant: ${reply.status.detail}`); } - - this.cachedPermissions.clear(); + grants.push(await PermissionGrant.parse(grantParams)); } + + return grants; } }; } @@ -538,7 +566,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 { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ messageParams: { messageType : DwnInterface.RecordsDelete, protocol : request.protocol, @@ -585,7 +613,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 { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ messageParams: { messageType : DwnInterface.RecordsQuery, protocol : agentRequest.messageParams.filter.protocol, @@ -661,7 +689,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 { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ messageParams: { messageType : DwnInterface.RecordsRead, protocol : request.protocol @@ -739,7 +767,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 { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ messageParams: { messageType : DwnInterface.RecordsWrite, protocol : dwnRequestParams.messageParams.protocol, @@ -780,4 +808,25 @@ export class DwnApi { }, }; } + + /** + * A static method to process connected grants for a delegate DID. + * + * This will store the grants as the DWN owner to be used later when impersonating the connected DID. + */ + static async processConnectedGrants({ grants, agent, delegateDid }: { + grants: DwnDataEncodedRecordsWriteMessage[], + agent: Web5Agent, + delegateDid: string, + }): Promise { + for (const grantMessage of grants) { + // use the delegateDid as the connectedDid of the grant as they do not yet support impersonation/delegation + const grant = await PermissionGrant.parse({ connectedDid: delegateDid, agent, message: grantMessage }); + // 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 grant.store(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/grant-revocation.ts b/packages/api/src/grant-revocation.ts new file mode 100644 index 000000000..4bcdaac24 --- /dev/null +++ b/packages/api/src/grant-revocation.ts @@ -0,0 +1,124 @@ +import { AgentPermissionsApi, DwnDataEncodedRecordsWriteMessage, DwnResponseStatus, getRecordAuthor, SendDwnRequest, Web5Agent } from '@web5/agent'; +import { DwnInterface } from '@web5/agent'; +import { Convert } from '@web5/common'; + +/** + * Represents the structured data model of a GrantRevocation record, encapsulating the essential fields that define. + */ +export interface GrantRevocationModel { + /** The DWN message used to construct this revocation */ + rawMessage: DwnDataEncodedRecordsWriteMessage; +} + +/** + * Represents the options for creating a new GrantRevocation instance. + */ +export interface GrantRevocationOptions { + /** The DID of the DWN tenant under which record operations are being performed. */ + connectedDid: string; + /** The DWN message used to construct this revocation */ + message: DwnDataEncodedRecordsWriteMessage; +} + +/** + * The `PermissionGrantRevocation` class encapsulates a permissions protocol `grant/revocation` record, providing a more + * developer-friendly interface for working with Decentralized Web Node (DWN) records. + * + * Methods are provided to manage the grant revocation's lifecycle, including writing to remote DWNs. + * + * @beta + */ +export class PermissionGrantRevocation implements GrantRevocationModel { + /** The PermissionsAPI used to interact with the underlying revocation */ + private _permissions: AgentPermissionsApi; + /** The DID to use as the author and default target for the underlying revocation */ + private _connectedDid: string; + /** The DWN `RecordsWrite` message, along with encodedData that represents the revocation */ + private _message: DwnDataEncodedRecordsWriteMessage; + + private constructor(permissions: AgentPermissionsApi, options: GrantRevocationOptions) { + this._permissions = permissions; + this._connectedDid = options.connectedDid; + + // Store the message that represents the grant. + this._message = options.message; + } + + /** The author of the underlying revocation message */ + get author() { + return getRecordAuthor(this._message); + } + + /** parses the grant revocation given am agent, connectedDid and data encoded records write message */ + static async parse({ connectedDid, agent, message }:{ + connectedDid: string; + agent: Web5Agent; + message: DwnDataEncodedRecordsWriteMessage; + }): Promise { + const permissions = new AgentPermissionsApi({ agent }); + return new PermissionGrantRevocation(permissions, { connectedDid, message }); + } + + /** The agent to use for this instantiation of the grant revocation */ + private get agent(): Web5Agent { + return this._permissions.agent; + } + + /** The raw `RecordsWrite` DWN message with encoded data that was used to instantiate this grant revocation */ + get rawMessage(): DwnDataEncodedRecordsWriteMessage { + return this._message; + } + + /** + * Send the current grant revocation to a remote DWN by specifying their DID + * If no DID is specified, the target is assumed to be the owner (connectedDID). + * + * @param target - the optional DID to send the grant revocation to, if none is set it is sent to the connectedDid + * @returns the status of the send grant revocation request + * + * @beta + */ + async send(target?: string): Promise { + target ??= this._connectedDid; + + const { encodedData, ...rawMessage } = this._message; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const sendRequestOptions: SendDwnRequest = { + messageType : DwnInterface.RecordsWrite, + author : this._connectedDid, + target : target, + dataStream, + rawMessage, + }; + + // Send the current/latest state to the target. + const { reply } = await this.agent.sendDwnRequest(sendRequestOptions); + return reply; + } + + /** + * Stores the current grant revocation to the owner's DWN. + * + * @param importGrant - if true, the grant revocation will signed by the owner before storing it to the owner's DWN. Defaults to false. + * @returns the status of the store request + * + * @beta + */ + async store(importRevocation?: boolean): Promise { + const { encodedData, ...rawMessage } = this.rawMessage; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const { reply, message } = await this.agent.processDwnRequest({ + author : this._connectedDid, + target : this._connectedDid, + messageType : DwnInterface.RecordsWrite, + signAsOwner : importRevocation, + rawMessage, + dataStream, + }); + + this._message = { ...message, encodedData: encodedData }; + return { status: reply.status }; + } +} \ No newline at end of file diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 8d2f52e15..b147a3185 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -23,6 +23,9 @@ export * from './did-api.js'; export * from './dwn-api.js'; +export * from './grant-revocation.js'; +export * from './permission-grant.js'; +export * from './permission-request.js'; export * from './protocol.js'; export * from './record.js'; export * from './vc-api.js'; diff --git a/packages/api/src/permission-grant.ts b/packages/api/src/permission-grant.ts new file mode 100644 index 000000000..a192d61c1 --- /dev/null +++ b/packages/api/src/permission-grant.ts @@ -0,0 +1,327 @@ +import type { + DwnDataEncodedRecordsWriteMessage, + DwnPermissionConditions, + DwnPermissionScope, + DwnResponseStatus, + SendDwnRequest, + Web5Agent +} from '@web5/agent'; + +import { Convert } from '@web5/common'; +import { + AgentPermissionsApi, + DwnInterface, + DwnPermissionGrant, +} from '@web5/agent'; +import { PermissionGrantRevocation } from './grant-revocation.js'; + +/** + * Represents the structured data model of a PermissionGrant record, encapsulating the essential fields that define + */ +export interface PermissionGrantModel { + /** + * The ID of the permission grant, which is the record ID DWN message. + */ + readonly id: string; + + /** + * The grantor of the permission. + */ + readonly grantor: string; + + /** + * The grantee of the permission. + */ + readonly grantee: string; + + /** + * The date at which the grant was given. + */ + readonly dateGranted: string; + + /** + * Optional string that communicates what the grant would be used for + */ + readonly description?: string; + + /** + * Optional CID of a permission request. This is optional because grants may be given without being officially requested + */ + readonly requestId?: string; + + /** + * Timestamp at which this grant will no longer be active. + */ + readonly dateExpires: string; + + /** + * Whether this grant is delegated or not. If `true`, the `grantedTo` will be able to act as the `grantedTo` within the scope of this grant. + */ + readonly delegated?: boolean; + + /** + * The scope of the allowed access. + */ + readonly scope: DwnPermissionScope; + + /** + * Optional conditions that must be met when the grant is used. + */ + readonly conditions?: DwnPermissionConditions; +} + +/** + * Represents the options for creating a new PermissionGrant instance. + */ +export interface PermissionGrantOptions { + /** The DID to use when interacting with the underlying DWN record representing the grant */ + connectedDid: string; + /** The underlying DWN `RecordsWrite` message along with encoded data that represent the grant */ + message: DwnDataEncodedRecordsWriteMessage; + /** The agent to use when interacting with the underlying DWN record representing the grant */ + agent: Web5Agent; +} + +/** + * The `PermissionGrant` class encapsulates a permissions protocol `grant` record, providing a more + * developer-friendly interface for working with Decentralized Web Node (DWN) records. + * + * Methods are provided to revoke, check if isRevoked, and manage the grant's lifecycle, including writing to remote DWNs. + * + * @beta + */ +export class PermissionGrant implements PermissionGrantModel { + /** The PermissionsAPI used to interact with the underlying permission grant */ + private _permissions: AgentPermissionsApi; + /** The DID to use as the author and default target for the underlying permission grant */ + private _connectedDid: string; + /** The underlying DWN `RecordsWrite` message along with encoded data that represent the grant */ + private _message: DwnDataEncodedRecordsWriteMessage; + /** The parsed grant object */ + private _grant: DwnPermissionGrant; + + private constructor({ api, connectedDid, message, grant }:{ + api: AgentPermissionsApi; + connectedDid: string; + message: DwnDataEncodedRecordsWriteMessage; + grant: DwnPermissionGrant; + }) { + this._permissions = api; + + // Store the connected DID for convenience. + this._connectedDid = connectedDid; + + // Store the message that represents the grant. + this._message = message; + + // Store the parsed grant object. + this._grant = grant; + } + + /** parses the grant given an agent, connectedDid and data encoded records write message */ + static async parse(options: PermissionGrantOptions): Promise { + //TODO: this does not have to be async https://github.com/TBD54566975/web5-js/pull/831/files + const grant = await DwnPermissionGrant.parse(options.message); + const api = new AgentPermissionsApi({ agent: options.agent }); + return new PermissionGrant({ ...options, grant, api }); + } + + /** The agent to use for this instantiation of the grant */ + private get agent(): Web5Agent { + return this._permissions.agent; + } + + /** The grant's ID, which is also the underlying record's ID */ + get id(): string { + return this._grant.id; + } + + /** The DID which granted the permission */ + get grantor(): string { + return this._grant.grantor; + } + + /** The DID which the permission was granted to */ + get grantee(): string { + return this._grant.grantee; + } + + /** The date the permission was granted */ + get dateGranted(): string { + return this._grant.dateGranted; + } + + /** (optional) Description of the permission grant */ + get description(): string | undefined { + return this._grant.description; + } + + /** (optional) The Id of the PermissionRequest if one was used */ + get requestId(): string | undefined { + return this._grant.requestId; + } + + /** The date on which the permission expires */ + get dateExpires(): string { + return this._grant.dateExpires; + } + + /** Whether or not the permission grant can be used to impersonate the grantor */ + get delegated(): boolean | undefined { + return this._grant.delegated; + } + + /** The permission scope under which the grant is valid */ + get scope(): DwnPermissionScope { + return this._grant.scope; + } + + /** The conditions under which the grant is valid */ + get conditions(): DwnPermissionConditions { + return this._grant.conditions; + } + + /** The raw `RecordsWrite` DWN message with encoded data that was used to instantiate this grant */ + get rawMessage(): DwnDataEncodedRecordsWriteMessage { + return this._message; + } + + /** + * Send the current grant to a remote DWN by specifying their DID + * If no DID is specified, the target is assumed to be the owner (connectedDID). + * + * @param target - the optional DID to send the grant to, if none is set it is sent to the connectedDid + * @returns the status of the send grant request + * + * @beta + */ + async send(target?: string): Promise { + target ??= this._connectedDid; + + const { encodedData, ...rawMessage } = this._message; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const sendRequestOptions: SendDwnRequest = { + messageType : DwnInterface.RecordsWrite, + author : this._connectedDid, + target : target, + dataStream, + rawMessage, + }; + + // Send the current/latest state to the target. + const { reply } = await this.agent.sendDwnRequest(sendRequestOptions); + return reply; + } + + /** + * Stores the current grant to the owner's DWN. + * + * @param importGrant - if true, the grant will signed by the owner before storing it to the owner's DWN. Defaults to false. + * @returns the status of the store request + * + * @beta + */ + async store(importGrant: boolean = false): Promise { + const { encodedData, ...rawMessage } = this.rawMessage; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const { reply, message } = await this.agent.processDwnRequest({ + store : true, + author : this._connectedDid, + target : this._connectedDid, + messageType : DwnInterface.RecordsWrite, + signAsOwner : importGrant, + rawMessage, + dataStream, + }); + + this._message = { ...message, encodedData: encodedData }; + return { status: reply.status }; + } + + /** + * Signs the current grant as the owner and optionally stores it to the owner's DWN. + * This is useful when importing a grant that was signed by someone else into your own DWN. + * + * @param store - if true, the grant will be stored to the owner's DWN after signing. Defaults to true. + * @returns the status of the import request + * + * @beta + */ + async import(store: boolean = false): Promise { + const { encodedData, ...rawMessage } = this.rawMessage; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const { reply, message } = await this.agent.processDwnRequest({ + store, + author : this._connectedDid, + target : this._connectedDid, + messageType : DwnInterface.RecordsWrite, + signAsOwner : true, + rawMessage, + dataStream, + }); + + this._message = { ...message, encodedData: encodedData }; + return { status: reply.status }; + } + + /** + * Revokes the grant and optionally stores the revocation to the owner's DWN. + * + * @param store - if true, the revocation will be stored to the owner's DWN. Defaults to true. + * @returns {PermissionGrantRevocation} the grant revocation object + * + * @beta + */ + async revoke(store: boolean = true): Promise { + const revocation = await this._permissions.createRevocation({ + store, + author : this._connectedDid, + grant : this._grant, + }); + + return PermissionGrantRevocation.parse({ + connectedDid : this._connectedDid, + agent : this.agent, + message : revocation.message, + }); + } + + /** + * Checks if the grant has been revoked. + * + * @param remote - if true, the check will be made against the remote DWN. Defaults to false. + * @returns true if the grant has been revoked, false otherwise. + * @throws if there is an error checking the revocation status. + * + * @beta + */ + isRevoked(remote: boolean = false): Promise { + return this._permissions.isGrantRevoked({ + author : this._connectedDid, + target : this.grantor, + grantRecordId : this.id, + remote + }); + } + + /** + * @returns the JSON representation of the grant + */ + toJSON(): DwnPermissionGrant { + return { + id : this.id, + grantor : this.grantor, + grantee : this.grantee, + dateGranted : this.dateGranted, + description : this.description, + requestId : this.requestId, + dateExpires : this.dateExpires, + delegated : this.delegated, + scope : this.scope, + conditions : this.conditions + }; + } +} \ No newline at end of file diff --git a/packages/api/src/permission-request.ts b/packages/api/src/permission-request.ts new file mode 100644 index 000000000..25d15a5fb --- /dev/null +++ b/packages/api/src/permission-request.ts @@ -0,0 +1,214 @@ +import { AgentPermissionsApi, DwnDataEncodedRecordsWriteMessage, DwnPermissionConditions, DwnPermissionRequest, DwnPermissionScope, DwnResponseStatus, SendDwnRequest, Web5Agent } from '@web5/agent'; +import { DwnInterface } from '@web5/agent'; +import { Convert } from '@web5/common'; +import { PermissionGrant } from './permission-grant.js'; + +/** + * Represents the structured data model of a PermissionsRequest record, encapsulating the essential fields that define + * the request's data and payload within a Decentralized Web Node (DWN). + */ +export interface PermissionRequestModel { + /** + * The ID of the permission request, which is the record ID DWN message. + */ + readonly id: string; + + /** + * The requester for of the permission. + */ + readonly requester: string; + + /** + * Optional string that communicates what the requested grant would be used for. + */ + readonly description?: string; + + /** + * Whether the requested grant is delegated or not. + * If `true`, the `requestor` will be able to act as the grantor of the permission within the scope of the requested grant. + */ + readonly delegated?: boolean; + + /** + * The scope of the allowed access. + */ + readonly scope: DwnPermissionScope; + + /** + * Optional conditions that must be met when the requested grant is used. + */ + readonly conditions?: DwnPermissionConditions; +} + +/** + * The `PermissionRequest` class encapsulates a permissions protocol `request` record, providing a more + * developer-friendly interface for working with Decentralized Web Node (DWN) records. + * + * Methods are provided to grant the request and manage the request's lifecycle, including writing to remote DWNs. + * + * @beta + */ +export class PermissionRequest implements PermissionRequestModel { + /** The PermissionsAPI used to interact with the underlying permission request */ + private _permissions: AgentPermissionsApi; + /** The DID to use as the author and default target for the underlying permission request */ + private _connectedDid: string; + /** The underlying DWN `RecordsWrite` message along with encoded data that represent the request */ + private _message: DwnDataEncodedRecordsWriteMessage; + /** The parsed permission request object */ + private _request: DwnPermissionRequest; + + private constructor({ api, connectedDid, message, request }: { + api: AgentPermissionsApi; + connectedDid: string; + message: DwnDataEncodedRecordsWriteMessage; + request: DwnPermissionRequest; + }) { + this._permissions = api; + this._connectedDid = connectedDid; + + // Store the parsed request object. + this._request = request; + + // Store the message that represents the grant. + this._message = message; + } + + /** parses the request given an agent, connectedDid and data encoded records write message */ + static async parse({ connectedDid, agent, message }:{ + connectedDid: string; + agent: Web5Agent; + message: DwnDataEncodedRecordsWriteMessage; + }): Promise { + //TODO: this does not have to be async https://github.com/TBD54566975/web5-js/pull/831/files + const request = await DwnPermissionRequest.parse(message); + const api = new AgentPermissionsApi({ agent }); + return new PermissionRequest({ api, connectedDid, message, request }); + } + + /** The agent to use for this instantiation of the request */ + private get agent(): Web5Agent { + return this._permissions.agent; + } + + /** The request's ID, which is also the underlying record's ID */ + get id() { + return this._request.id; + } + + /** The DID that is requesting a permission */ + get requester() { + return this._request.requester; + } + + /** (optional) Description of the permission request */ + get description() { + return this._request.description; + } + + /** Whether or not the permission request can be used to impersonate the grantor */ + get delegated() { + return this._request.delegated; + } + + /** The permission scope under which the requested grant would be valid */ + get scope() { + return this._request.scope; + } + + /** The conditions under which the requested grant would be valid */ + get conditions() { + return this._request.conditions; + } + + /** The `RecordsWrite` DWN message with encoded data that was used to instantiate this request */ + get rawMessage(): DwnDataEncodedRecordsWriteMessage { + return this._message; + } + + /** + * Send the current permission request to a remote DWN by specifying their DID + * If no DID is specified, the target is assumed to be the owner (connectedDID). + * + * @param target - the optional DID to send the permission request to, if none is set it is sent to the connectedDid + * @returns the status of the send permission request + * + * @beta + */ + async send(target?: string): Promise { + target ??= this._connectedDid; + + const { encodedData, ...rawMessage } = this._message; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const sendRequestOptions: SendDwnRequest = { + messageType : DwnInterface.RecordsWrite, + author : this._connectedDid, + target : target, + dataStream, + rawMessage, + }; + + // Send the current/latest state to the target. + const { reply } = await this.agent.sendDwnRequest(sendRequestOptions); + return reply; + } + + /** + * Stores the current permission request to the owner's DWN. + * + * @param importGrant - if true, the permission request will signed by the owner before storing it to the owner's DWN. Defaults to false. + * @returns the status of the store request + * + * @beta + */ + async store(): Promise { + const { encodedData, ...rawMessage } = this.rawMessage; + const dataStream = new Blob([ Convert.base64Url(encodedData).toUint8Array() ]); + + const { reply, message } = await this.agent.processDwnRequest({ + author : this._connectedDid, + target : this._connectedDid, + messageType : DwnInterface.RecordsWrite, + rawMessage, + dataStream, + }); + + this._message = { ...message, encodedData: encodedData }; + return { status: reply.status }; + } + + /** + * Grants the permission request to the requester. + * + * @param dateExpires - the date when the permission grant will expire. + * @param store - if true, the permission grant will be stored in the owner's DWN. Defaults to true. + * @returns {PermissionGrant} the granted permission. + * + * @beta + */ + async grant(dateExpires: string, store: boolean = true): Promise { + const { message } = await this._permissions.createGrant({ + requestId : this.id, + grantedTo : this.requester, + scope : this.scope, + delegated : this.delegated, + author : this._connectedDid, + store, + dateExpires, + }); + + return PermissionGrant.parse({ + connectedDid : this._connectedDid, + agent : this.agent, + message + }); + } + + /** + * @returns the JSON representation of the permission request + */ + toJSON(): PermissionRequestModel { + return this._request; + } +} \ No newline at end of file diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index d8951d67a..e905332c5 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -11,7 +11,6 @@ import type { Web5Agent, } from '@web5/agent'; -import { PortableDid } from '@web5/dids'; import { Web5UserAgent } from '@web5/user-agent'; import { DwnRegistrar, WalletConnect } from '@web5/agent'; @@ -276,16 +275,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, 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..16ae2f8bc 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 { AgentPermissionsApi, DwnDateSort, DwnInterface, getRecordAuthor, 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 { DwnInterfaceName, DwnMethodName, PermissionsProtocol, Time } from '@tbd54566975/dwn-sdk-js'; +import { PermissionGrant } from '../src/permission-grant.js'; 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,19 +1367,145 @@ describe('DwnApi', () => { }); }); - describe('grants.fetchConnectedGrants()', () => { - it('throws if no signerDID is set', async () => { - // make sure signerDID is undefined + describe('connected.findPermissionGrantForRequest', () => { + it('caches result', async () => { + // create a grant for bob + const deviceXGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + delegated : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // simulate a connect where bobDid can impersonate aliceDid + dwnBob['connectedDid'] = aliceDid.uri; + dwnBob['delegateDid'] = bobDid.uri; + await DwnApi.processConnectedGrants({ + agent : testHarness.agent, + delegateDid : bobDid.uri, + grants : [ deviceXGrant.rawMessage ] + }); + + const fetchGrantsSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchGrants'); + + // find the grant for a request + let grantForRequest = await dwnBob['connected'].findPermissionGrantForMessage({ + messageParams: { + messageType : DwnInterface.RecordsWrite, + protocol : 'http://example.com/protocol' + } + }); + + // expect to have the grant + expect(grantForRequest).to.exist; + expect(grantForRequest.id).to.equal(deviceXGrant.id); + expect(fetchGrantsSpy.callCount).to.equal(1); + + fetchGrantsSpy.resetHistory(); + + // attempt to find the grant again + grantForRequest = await dwnBob['connected'].findPermissionGrantForMessage({ + messageParams: { + messageType : DwnInterface.RecordsWrite, + protocol : 'http://example.com/protocol' + } + }); + expect(grantForRequest).to.exist; + expect(grantForRequest.id).to.equal(deviceXGrant.id); + expect(fetchGrantsSpy.callCount).to.equal(0); + + // should call again if cached:false is passed + grantForRequest = await dwnBob['connected'].findPermissionGrantForMessage({ + messageParams: { + messageType : DwnInterface.RecordsWrite, + protocol : 'http://example.com/protocol' + }, + cached: false + }); + expect(grantForRequest).to.exist; + expect(grantForRequest.id).to.equal(deviceXGrant.id); + expect(fetchGrantsSpy.callCount).to.equal(1); + + // reset the spy + fetchGrantsSpy.resetHistory(); + expect(fetchGrantsSpy.callCount).to.equal(0); + + // call for a different grant + try { + await dwnBob['connected'].findPermissionGrantForMessage({ + messageParams: { + messageType : DwnInterface.RecordsRead, + protocol : 'http://example.com/protocol' + } + }); + expect.fail('Should have thrown an error'); + } catch(error:any) { + expect(error.message).to.equal('AgentDwnApi: No permissions found for RecordsRead: http://example.com/protocol'); + } + expect(fetchGrantsSpy.callCount).to.equal(1); + + // call again to ensure grants which are not found are not cached + try { + await dwnBob['connected'].findPermissionGrantForMessage({ + messageParams: { + messageType : DwnInterface.RecordsRead, + protocol : 'http://example.com/protocol' + } + }); + expect.fail('Should have thrown an error'); + } catch(error:any) { + expect(error.message).to.equal('AgentDwnApi: No permissions found for RecordsRead: http://example.com/protocol'); + } + + expect(fetchGrantsSpy.callCount).to.equal(2); // should have been called again + }); + + it('throws if no delegateDid is set', async () => { + // make sure delegateDid is undefined dwnAlice['delegateDid'] = undefined; try { - await dwnAlice.grants.fetchConnectedGrants(); + await dwnAlice['connected'].findPermissionGrantForMessage({ + messageParams: { + messageType : DwnInterface.RecordsWrite, + protocol : 'http://example.com/protocol' + } + }); expect.fail('Error was not thrown'); } catch (e) { - expect(e.message).to.equal('AgentDwnApi: Cannot fetch grants without a signer DID'); + expect(e.message).to.equal('AgentDwnApi: Cannot find connected grants without a signer DID'); } }); + }); + + describe('permissions.grant', () => { + it('uses the connected DID to create a grant if no delegate DID is set', async () => { + // scenario: create a permission grant for bob, confirm that alice is the signer + + // create a permission grant for bob + const deviceXGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + const author = getRecordAuthor(deviceXGrant.rawMessage); + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); // connected DID should be alice + expect(author).to.equal(aliceDid.uri); + }); + + it('uses the delegate DID to create a grant if set', async () => { + // scenario: create a permission grant for aliceDeviceX, confirm that deviceX is the signer - it('caches results', async () => { // create an identity for deviceX const aliceDeviceX = await testHarness.agent.identity.create({ store : true, @@ -1390,322 +1513,412 @@ describe('DwnApi', () => { didMethod : 'jwk' }); - // set the device identity as the signerDID + // set the delegate DID, this happens during a connect flow dwnAlice['delegateDid'] = aliceDeviceX.did.uri; - const recordsWriteGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, - grantedTo : aliceDeviceX.did.uri, + // create a permission grant for deviceX + const deviceXGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Write, protocol: 'http://example.com/protocol' } + 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 author = getRecordAuthor(deviceXGrant.rawMessage); + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); // connected DID should be alice + expect(dwnAlice['delegateDid']).to.equal(aliceDeviceX.did.uri); // delegate DID should be deviceX + expect(author).to.equal(aliceDeviceX.did.uri); + }); + + it('creates and stores a grant', async () => { + // scenario: create a grant for deviceX, confirm the grant exists + + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' }); - expect(writeReplyX.status.code).to.equal(202); - const recordsReadGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, + // create a grant for deviceX + const deviceXGrant = await dwnAlice.permissions.grant({ + store : true, grantedTo : aliceDeviceX.did.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { interface: DwnInterfaceName.Records, method: DwnMethodName.Read, protocol: 'http://example.com/protocol' } + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + 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 + // query for the grant + const fetchedGrants = await dwnAlice.records.query({ + message: { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.grantPath, + } + } }); - expect(readReplyX.status.code).to.equal(202); + // expect to have the 1 grant created for deviceX + expect(fetchedGrants.status.code).to.equal(200); + expect(fetchedGrants.records).to.exist; + expect(fetchedGrants.records!.length).to.equal(1); + expect(fetchedGrants.records![0].id).to.equal(deviceXGrant.rawMessage.recordId); + }); - // 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(); + it('creates a grant without storing it', async () => { + // scenario: create a grant for deviceX, confirm the grant does not exist - expect(grants).to.exist; - expect(grants.length).to.equal(2); + // create an identity for deviceX + const aliceDeviceX = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Alice Device X' }, + didMethod : 'jwk' + }); - // ensure the spy to be called three times, once for fetch and once for each revocation check - expect(dwnRequestSpy.callCount).to.equal(3); + // create a grant for deviceX store is set to false by default + const deviceXGrant = await dwnAlice.permissions.grant({ + grantedTo : aliceDeviceX.did.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); - // get the grants again to ensure they are cached - const cachedGrants = await dwnAlice.grants.fetchConnectedGrants(); + // query for the grant + let fetchedGrants = await dwnAlice.records.query({ + message: { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.grantPath, + } + } + }); - expect(cachedGrants).to.exist; - expect(cachedGrants.length).to.equal(2); + // expect to have no grants + expect(fetchedGrants.status.code).to.equal(200); + expect(fetchedGrants.records).to.exist; + expect(fetchedGrants.records!.length).to.equal(0); - // ensure the spy callCount was unchanged - expect(dwnRequestSpy.callCount).to.equal(3); + // store the grant + const processGrantReply = await deviceXGrant.store(); + expect(processGrantReply.status.code).to.equal(202); - // 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' } + // query for the grants again + fetchedGrants = await dwnAlice.records.query({ + message: { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.grantPath, + } + } }); - // 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 to have the 1 grant created for deviceX + expect(fetchedGrants.status.code).to.equal(200); + expect(fetchedGrants.records).to.exist; + expect(fetchedGrants.records!.length).to.equal(1); + expect(fetchedGrants.records![0].id).to.equal(deviceXGrant.rawMessage.recordId); + }); + }); + + describe('permissions.request', () => { + it('uses the connected DID to create a request if no delegate DID is set', async () => { + // scenario: create a permission request for bob, confirm the request exists + + // create a permission request for bob + const deviceXRequest = await dwnAlice.permissions.request({ + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } }); - 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); + const author = getRecordAuthor(deviceXRequest.rawMessage); + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); // connected DID should be alice + expect(author).to.equal(aliceDid.uri); }); - it('fetches grants for the signer', async () => { - // scenario: alice creates grants for recipients deviceY and deviceX - // the grantee fetches their own grants respectively + it('uses the delegate DID to create a request if set', async () => { + // scenario: create a permission request for aliceDeviceX, the signer - // create an identity for deviceX and deviceY + // 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, this normally happens when the identity is connected + // set the delegate DID 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' } + // create a permission request for deviceX + const deviceXRequest = await dwnAlice.permissions.request({ + store : true, + 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 author = getRecordAuthor(deviceXRequest.rawMessage); + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); // connected DID should be alice + expect(dwnAlice['delegateDid']).to.equal(aliceDeviceX.did.uri); // delegate DID should be deviceX + expect(author).to.equal(aliceDeviceX.did.uri); + }); + + it('creates a permission request and stores it', async () => { + // scenario: create a permission request confirm the request exists + + // create a permission request + const deviceXRequest = await dwnAlice.permissions.request({ + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } }); - expect(writeReplyX.status.code).to.equal(202); + // query for the request + const fetchedRequests = await dwnAlice.records.query({ + message: { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.requestPath, + } + } + }); - 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' } + // expect to have the 1 request created + expect(fetchedRequests.status.code).to.equal(200); + expect(fetchedRequests.records).to.exist; + expect(fetchedRequests.records!.length).to.equal(1); + expect(fetchedRequests.records![0].id).to.equal(deviceXRequest.rawMessage.recordId); + }); + + it('creates a permission request without storing it', async () => { + // scenario: create a permission request confirm the request does not exist + + // create a permission request store is set to false by default + const deviceXRequest = await dwnAlice.permissions.request({ + scope: { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + 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 + // query for the request + let fetchedRequests = await dwnAlice.records.query({ + message: { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.requestPath, + } + } }); - expect(readReplyX.status.code).to.equal(202); + // expect to have no requests + expect(fetchedRequests.status.code).to.equal(200); + expect(fetchedRequests.records).to.exist; + expect(fetchedRequests.records!.length).to.equal(0); - const deviceXGrantRecordIds = [ - recordsWriteGrant.dataEncodedMessage.recordId, - recordsReadGrant.dataEncodedMessage.recordId - ]; + // store the request + const storeDeviceXRequest = await deviceXRequest.store(); + expect(storeDeviceXRequest.status.code).to.equal(202); - // fetch the grants for deviceX from the app agent - const fetchedDeviceXGrants = await dwnAlice.grants.fetchConnectedGrants(); + // query for the requests again + fetchedRequests = await dwnAlice.records.query({ + message: { + filter: { + protocol : PermissionsProtocol.uri, + protocolPath : PermissionsProtocol.requestPath, + } + } + }); - // expect to have the 5 grants created for deviceX - expect(fetchedDeviceXGrants.length).to.equal(2); - expect(fetchedDeviceXGrants.map(grant => grant.recordId)).to.have.members(deviceXGrantRecordIds); + // expect to have the 1 request created for deviceX + expect(fetchedRequests.status.code).to.equal(200); + expect(fetchedRequests.records).to.exist; + expect(fetchedRequests.records!.length).to.equal(1); + expect(fetchedRequests.records![0].id).to.equal(deviceXRequest.rawMessage.recordId); }); + }); - it('should throw if the grant query returns anything other than a 200', async () => { - // setting a signerDID, otherwise fetchConnectedGrants will throw - dwnAlice['delegateDid'] = 'did:example:123'; + describe('permissions.queryRequests', () => { + it('uses the connected DID to query for permission requests if no delegate DID is set', async () => { + // scenario: query for permission requests, confirm that alice is the author of the query - // 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(); + // create a permission request + await dwnAlice.permissions.request({ + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); - expect.fail('Expected fetchGrants to throw'); - } catch(error: any) { - expect(error.message).to.equal('AgentDwnApi: Failed to fetch grants: unknown error'); - } + // spy on the fetch requests method to confirm the author + const fetchRequestsSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchRequests'); + + // Query for requests + const deviceXRequests = await dwnAlice.permissions.queryRequests(); + expect(deviceXRequests.length).to.equal(1); + + // confirm alice is the connected DID + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); + expect(fetchRequestsSpy.callCount).to.equal(1); + expect(fetchRequestsSpy.args[0][0].author).to.equal(aliceDid.uri); }); - it('should not return revoked grants', async () => { - // create an identity for deviceX and deviceY + it('uses the delegate DID to query for permission requests if set', async () => { + // scenario: query for permission requests for aliceDeviceX, confirm that deviceX is the signer + + // 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 for alice, this normally happens during a connect flow + // spy on the fetch requests method to confirm the author + const fetchRequestsSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchRequests'); + + // set the delegate DID, this happens during a connect flow 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' } + // create a permission request + await dwnAlice.permissions.request({ + store : true, + 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]), - }); - expect(writeReply.status.code).to.equal(202); + // Query for requests + const deviceXRequests = await dwnAlice.permissions.queryRequests(); + expect(deviceXRequests.length).to.equal(1); - // 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 - }); - expect(writeReplyX.status.code).to.equal(202); + // confirm alice is the connected DID, and aliceDeviceX is the delegate DID + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); + expect(dwnAlice['delegateDid']).to.equal(aliceDeviceX.did.uri); - 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' } - }); + // confirm the author is aliceDeviceX + expect(fetchRequestsSpy.callCount).to.equal(1); + expect(fetchRequestsSpy.args[0][0].author).to.equal(aliceDeviceX.did.uri); + }); - // 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]), + it('should query for permission requests from the local DWN', async () => { + // bob creates two different requests and stores it + const bobRequest = await dwnBob.permissions.request({ + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-1' + } }); - 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 + // query for the requests + const fetchedRequests = await dwnBob.permissions.queryRequests(); + expect(fetchedRequests.length).to.equal(1); + expect(fetchedRequests[0].id).to.equal(bobRequest.id); + }); + + it('should query for permission requests from the remote DWN', async () => { + // bob creates two different requests and stores it + const bobRequest = await dwnBob.permissions.request({ + scope: { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-1' + } }); - 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); + // send the request to alice's DWN + const sentToAlice = await bobRequest.send(aliceDid.uri); + expect(sentToAlice.status.code).to.equal(202); - // expect to have the 2 grants created for deviceX - expect(fetchedDeviceXGrants.length).to.equal(2); + // alice Queries the remote DWN for the requests + const fetchedRequests = await dwnAlice.permissions.queryRequests({ + from: aliceDid.uri + }); + expect(fetchedRequests.length).to.equal(1); + expect(fetchedRequests[0].id).to.equal(bobRequest.id); + }); - // revoke a grant - const writeGrant = await PermissionGrant.parse(recordsWriteGrant.dataEncodedMessage); - const recordsWriteGrantRevoke = await testHarness.agent.dwn.createRevocation({ - author : aliceDid.uri, - grant : writeGrant, + it('should filter by protocol', async () => { + // bob creates two different requests and stores it + const bobRequest1 = await dwnBob.permissions.request({ + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-1' + } }); - // process the grant to alice's DWN - const revokeReply = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : recordsWriteGrantRevoke.recordsWrite.message, - author : aliceDid.uri, - target : aliceDid.uri, - dataStream : new Blob([recordsWriteGrantRevoke.permissionRevocationBytes]), + const bobRequest2 = await dwnBob.permissions.request({ + store : true, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-2' + } }); - 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(); - expect(fetchedDeviceXGrantsRevoked.length).to.equal(1); // only the read grant should be available + // query for the requests with protocol-1 + const fetchedRequests = await dwnBob.permissions.queryRequests({ + protocol: 'http://example.com/protocol-1' + }); + expect(fetchedRequests.length).to.equal(1); + expect(fetchedRequests[0].id).to.equal(bobRequest1.id); - // ensure the revoked grant is not included - expect(fetchedDeviceXGrantsRevoked.map(grant => grant.recordId)).to.not.include(recordsWriteGrant.dataEncodedMessage.recordId); + // query for the requests with protocol-2 + const fetchedRequests2 = await dwnBob.permissions.queryRequests({ + protocol: 'http://example.com/protocol-2' + }); + expect(fetchedRequests2.length).to.equal(1); + expect(fetchedRequests2[0].id).to.equal(bobRequest2.id); }); }); - describe('grants.findConnectedPermissionGrant', () => { - it('throws if no signerDID is set', async () => { - // make sure signerDID is undefined - dwnAlice['delegateDid'] = undefined; - try { - await dwnAlice.grants.findConnectedPermissionGrant({ - messageParams: { - messageType : DwnInterface.RecordsWrite, - protocol : 'http://example.com/protocol' - } - }); - expect.fail('Error was not thrown'); - } catch (e) { - expect(e.message).to.equal('AgentDwnApi: Cannot find connected grants without a signer DID'); - } - }); - }); + describe('permissions.queryGrants', () => { + it('uses the connected DID to query for grants if no delegate DID is set', async () => { + // scenario: query for grants, confirm that alice is the author of the query - 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'); - } + // spy on the fetch grants method to confirm the author + const fetchGrantsSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchGrants'); + + // Query for grants + const deviceXGrants = await dwnAlice.permissions.queryGrants(); + expect(deviceXGrants.length).to.equal(0); + + // confirm alice is the connected DID + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); + expect(fetchGrantsSpy.callCount).to.equal(1); + expect(fetchGrantsSpy.args[0][0].author).to.equal(aliceDid.uri); }); - }); - describe('grants.isGrantRevoked', () => { - 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 + it('uses the delegate DID to query for grants if set', async () => { + // scenario: query for grants for aliceDeviceX, confirm that deviceX is the signer // create an identity for deviceX const aliceDeviceX = await testHarness.agent.identity.create({ @@ -1714,10 +1927,30 @@ describe('DwnApi', () => { didMethod : 'jwk' }); - // create records grants for deviceX - const deviceXGrant = await testHarness.agent.dwn.createGrant({ - grantedFrom : aliceDid.uri, - grantedTo : aliceDeviceX.did.uri, + // spy on the fetch grants method to confirm the author + const fetchGrantsSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchGrants'); + + // set the delegate DID, this happens during a connect flow + dwnAlice['delegateDid'] = aliceDeviceX.did.uri; + + // Query for grants + const deviceXGrants = await dwnAlice.permissions.queryGrants(); + expect(deviceXGrants.length).to.equal(0); + + // confirm alice is the connected DID, and aliceDeviceX is the delegate DID + expect(dwnAlice['connectedDid']).to.equal(aliceDid.uri); + expect(dwnAlice['delegateDid']).to.equal(aliceDeviceX.did.uri); + + // confirm the author is aliceDeviceX + expect(fetchGrantsSpy.callCount).to.equal(1); + expect(fetchGrantsSpy.args[0][0].author).to.equal(aliceDeviceX.did.uri); + }); + + it('should query for permission grants from the local DWN', async () => { + // alice creates a grant for bob and stores it + const bobGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), scope : { interface : DwnInterfaceName.Records, @@ -1726,59 +1959,227 @@ 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]), + // query for the grants + const fetchedGrants = await dwnAlice.permissions.queryGrants(); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant.id); + }); + + it('should query for permission grants from the remote DWN', async () => { + // alice creates a grant for bob and doesn't store it locally + const bobGrant = await dwnAlice.permissions.grant({ + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } }); - 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 - ); + // alice queries the remote DWN, should not find any grants + let fetchedGrants = await dwnAlice.permissions.queryGrants(); + expect(fetchedGrants.length).to.equal(0); - expect(isRevoked).to.equal(false); + // send the grant to alice's remote DWN + const sentToAlice = await bobGrant.send(aliceDid.uri); + expect(sentToAlice.status.code).to.equal(202); - // revoke the grant - const writeGrant = await PermissionGrant.parse(deviceXGrant.dataEncodedMessage); - const revokeGrant = await testHarness.agent.dwn.createRevocation({ - author : aliceDid.uri, - grant : writeGrant, + // alice queries the remote DWN for the grants + fetchedGrants = await dwnAlice.permissions.queryGrants({ + from: aliceDid.uri }); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant.id); + }); - const revokeReply = await testHarness.agent.dwn.processRequest({ - messageType : DwnInterface.RecordsWrite, - rawMessage : revokeGrant.recordsWrite.message, + it('should filter by protocol', async () => { + // alice creates two different grants for bob + const bobGrant1 = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-1' + } + }); + + const bobGrant2 = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol-2' + } + }); + + // query for the grants with protocol-1 + let fetchedGrants = await dwnAlice.permissions.queryGrants({ + protocol: 'http://example.com/protocol-1' + }); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant1.id); + + // query for the grants with protocol-2 + fetchedGrants = await dwnAlice.permissions.queryGrants({ + protocol: 'http://example.com/protocol-2' + }); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant2.id); + }); + + it('should filter by grantee', async () => { + const { did: carolDid } = await testHarness.agent.identity.create({ + store : false, + metadata : { name: 'Carol' }, + didMethod : 'jwk' + }); + + // alice creates a grant for bob and stores it + const bobGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // alice creates a grant for carol and stores it + const carolGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : carolDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + // query for the grants with bob as the grantee + let fetchedGrants = await dwnAlice.permissions.queryGrants({ + grantee: bobDid.uri + }); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant.id); + + // query for the grants with carol as the grantee + fetchedGrants = await dwnAlice.permissions.queryGrants({ + grantee: carolDid.uri + }); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(carolGrant.id); + }); + + it('should filter by grantor', async () => { + const { did: carolDid } = await testHarness.agent.identity.create({ + store : true, + metadata : { name: 'Carol' }, + didMethod : 'jwk' + }); + + // alice creates a grant for bob + const { message: messageGrantFromAlice } = await testHarness.agent.permissions.createGrant({ + store : false, author : aliceDid.uri, - target : aliceDid.uri, - dataStream : new Blob([revokeGrant.permissionRevocationBytes]), - }); - 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 - ); - expect(isRevoked).to.equal(true); + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + const grantFromAlice = await PermissionGrant.parse({ + agent : testHarness.agent, + connectedDid : bobDid.uri, + message : messageGrantFromAlice + }); + + // import the grant + const importFromAlice = await grantFromAlice.import(true); + expect(importFromAlice.status.code).to.equal(202); + + // carol creates a grant for bob + const { message: messageGrantFromCarol } = await testHarness.agent.permissions.createGrant({ + store : false, + author : carolDid.uri, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); + + const grantFromCarol = await PermissionGrant.parse({ + agent : testHarness.agent, + connectedDid : bobDid.uri, + message : messageGrantFromCarol + }); + + const importGrantCarol = await grantFromCarol.import(true); + expect(importGrantCarol.status.code).to.equal(202); + + // query for the grants with alice as the grantor + const fetchedGrantsAlice = await dwnBob.permissions.queryGrants({ + grantor: aliceDid.uri + }); + expect(fetchedGrantsAlice.length).to.equal(1); + expect(fetchedGrantsAlice[0].id).to.equal(grantFromAlice.id); + + // query for the grants with carol as the grantor + const fetchedGrantsCarol = await dwnBob.permissions.queryGrants({ + grantor: carolDid.uri + }); + expect(fetchedGrantsCarol.length).to.equal(1); + expect(fetchedGrantsCarol[0].id).to.equal(grantFromCarol.id); }); - 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' } } }); + it('should check revocation status if option is set', async () => { + // alice creates a grant for bob and stores it + const bobGrant = await dwnAlice.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'http://example.com/protocol' + } + }); - try { - await dwnAlice.grants.isGrantRevoked(aliceDid.uri, aliceDid.uri, 'some-record-id'); - expect.fail('Expected isGrantRevoked to throw'); - } catch (error:any) { - expect(error.message).to.equal('AgentDwnApi: Failed to check if grant is revoked: unknown error'); - } + // query for the grants + let fetchedGrants = await dwnAlice.permissions.queryGrants({ + checkRevoked: true + }); + + // expect to have the 1 grant created + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant.id); + + // stub the isRevoked method to return true + sinon.stub(AgentPermissionsApi.prototype, 'isGrantRevoked').resolves(true); + + // query for the grants + fetchedGrants = await dwnAlice.permissions.queryGrants({ + checkRevoked: true + }); + expect(fetchedGrants.length).to.equal(0); + + // return without checking revoked status + fetchedGrants = await dwnAlice.permissions.queryGrants(); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].id).to.equal(bobGrant.id); }); }); }); \ No newline at end of file diff --git a/packages/api/tests/permission-grant.spec.ts b/packages/api/tests/permission-grant.spec.ts new file mode 100644 index 000000000..77c131923 --- /dev/null +++ b/packages/api/tests/permission-grant.spec.ts @@ -0,0 +1,460 @@ +import type { BearerDid } from '@web5/dids'; + +import sinon from 'sinon'; +import { expect } from 'chai'; +import { Web5UserAgent } from '@web5/user-agent'; +import { testDwnUrl } from './utils/test-config.js'; + +// NOTE: @noble/secp256k1 requires globalThis.crypto polyfill for node.js <=18: https://github.com/paulmillr/noble-secp256k1/blob/main/README.md#usage +// Remove when we move off of node.js v18 to v20, earliest possible time would be Oct 2023: https://github.com/nodejs/release#release-schedule +import { webcrypto } from 'node:crypto'; +import { PlatformAgentTestHarness } from '@web5/agent'; +import { DwnInterfaceName, DwnMethodName, Time } from '@tbd54566975/dwn-sdk-js'; +import { PermissionGrant } from '../src/permission-grant.js'; +import { DwnApi } from '../src/dwn-api.js'; +// @ts-ignore +if (!globalThis.crypto) globalThis.crypto = webcrypto; + +let testDwnUrls: string[] = [testDwnUrl]; + +describe('PermissionGrant', () => { + let aliceDid: BearerDid; + let bobDid: BearerDid; + let aliceDwn: DwnApi; + let bobDwn: DwnApi; + let testHarness: PlatformAgentTestHarness; + + before(async () => { + testHarness = await PlatformAgentTestHarness.setup({ + agentClass : Web5UserAgent, + agentStores : 'memory' + }); + + }); + + beforeEach(async () => { + sinon.restore(); + await testHarness.clearStorage(); + await testHarness.createAgentDid(); + + // Create an "alice" Identity to author the DWN messages. + const alice = await testHarness.createIdentity({ name: 'Alice', testDwnUrls }); + await testHarness.agent.identity.manage({ portableIdentity: await alice.export() }); + aliceDid = alice.did; + + // Create a "bob" Identity to author the DWN messages. + const bob = await testHarness.createIdentity({ name: 'Bob', testDwnUrls }); + await testHarness.agent.identity.manage({ portableIdentity: await bob.export() }); + bobDid = bob.did; + + aliceDwn = new DwnApi({ agent: testHarness.agent, connectedDid: aliceDid.uri }); + bobDwn = new DwnApi({ agent: testHarness.agent, connectedDid: bobDid.uri }); + }); + + after(async () => { + sinon.restore(); + await testHarness.clearStorage(); + await testHarness.closeStorage(); + }); + + describe('parse()',() => { + it('parses a grant message', async () => { + const { grant, message } = await testHarness.agent.permissions.createGrant({ + store : false, + author : aliceDid.uri, + grantedTo : bobDid.uri, + requestId : '123', + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + description : 'This is a grant', + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + const parsedGrant = await PermissionGrant.parse({ + agent : testHarness.agent, + connectedDid : bobDid.uri, + message, + }); + + expect(parsedGrant.toJSON()).to.deep.equal(grant); + expect(parsedGrant.rawMessage).to.deep.equal(message); + expect(parsedGrant.id).to.equal(grant.id); + expect(parsedGrant.grantor).to.equal(grant.grantor); + expect(parsedGrant.grantee).to.equal(grant.grantee); + expect(parsedGrant.scope).to.deep.equal(grant.scope); + expect(parsedGrant.conditions).to.deep.equal(grant.conditions); + expect(parsedGrant.requestId).to.equal(grant.requestId); + expect(parsedGrant.dateGranted).to.equal(grant.dateGranted); + expect(parsedGrant.dateExpires).to.equal(grant.dateExpires); + expect(parsedGrant.description).to.equal(grant.description); + expect(parsedGrant.delegated).to.equal(grant.delegated); + }); + + //TODO: this should happen in the `dwn-sdk-js` helper + xit('throws for an invalid grant'); + }); + + describe('send()', () => { + it('sends to connectedDID target by default', async () => { + // Create a grant message. + const grant = await aliceDwn.permissions.grant({ + store : false, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + // query the remote for the grant + let fetchedRemote = await aliceDwn.permissions.queryGrants({ + from: aliceDid.uri, + }); + expect(fetchedRemote.length).to.equal(0); + + // send the grant + const sent = await grant.send(); + expect(sent.status.code).to.equal(202); + + // query the remote for the grant, should now exist + fetchedRemote = await aliceDwn.permissions.queryGrants({ + from: aliceDid.uri, + }); + expect(fetchedRemote.length).to.equal(1); + }); + + it('sends to a remote target', async () => { + // Alice creates a grant for Bob + const grant = await aliceDwn.permissions.grant({ + store : false, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + // alice sends it to her own DWN + const aliceSent = await grant.send(); + expect(aliceSent.status.code).to.equal(202); + + // bob queries alice's remote for a grant + const fetchedFromAlice = await bobDwn.permissions.queryGrants({ + from: aliceDid.uri, + }); + expect(fetchedFromAlice.length).to.equal(1); + + // fetch from bob's remote. should have no grants + let fetchedRemote = await bobDwn.permissions.queryGrants({ + from: bobDid.uri, + }); + expect(fetchedRemote.length).to.equal(0); + + // fetchedGrant + const fetchedGrant = fetchedFromAlice[0]; + + // import the grant (signing as owner), but do not store it + const imported = await fetchedGrant.import(false); + expect(imported.status.code).to.equal(202); + + // send the grant to bob's remote + const sent = await fetchedGrant.send(bobDid.uri); + expect(sent.status.code).to.equal(202); + // // send the gran + // the grant should now exist in bob's remote + fetchedRemote = await bobDwn.permissions.queryGrants({ + from: bobDid.uri, + }); + expect(fetchedRemote.length).to.equal(1); + expect(fetchedRemote[0].toJSON()).to.deep.equal(grant.toJSON()); + }); + }); + + describe('store()', () => { + it('stores the grant as is', async () => { + // create a grant not marked as stored + const grant = await aliceDwn.permissions.grant({ + store : false, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + // validate the grant does not exist in the DWN + let fetchedGrants = await aliceDwn.permissions.queryGrants(); + expect(fetchedGrants.length).to.equal(0); + + // store the grant + const stored = await grant.store(); + expect(stored.status.code).to.equal(202); + + // validate the grant now exists in the DWN + fetchedGrants = await aliceDwn.permissions.queryGrants(); + expect(fetchedGrants.length).to.equal(1); + expect(fetchedGrants[0].toJSON()).to.deep.equal(grant.toJSON()); + }); + + it('stores the grant and imports it', async () => { + // alice creates a grant and sends it to her remote + const grant = await aliceDwn.permissions.grant({ + store : false, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + const sent = await grant.send(); + expect(sent.status.code).to.equal(202); + + // bob queries alice's remote for a grant + let fetchedFromAlice = await bobDwn.permissions.queryGrants({ + from: aliceDid.uri, + }); + expect(fetchedFromAlice.length).to.equal(1); + + // attempt to store it without importing, should fail + let fetchedGrant = fetchedFromAlice[0]; + let stored = await fetchedGrant.store(); + expect(stored.status.code).to.equal(401); + + // attempt to fetch from local to ensure it was not imported + let fetchedLocal = await bobDwn.permissions.queryGrants(); + expect(fetchedLocal.length).to.equal(0); + + // store the grant and import it + stored = await fetchedGrant.store(true); + expect(stored.status.code).to.equal(202); + + // fetch from local to ensure it was imported + fetchedLocal = await bobDwn.permissions.queryGrants(); + expect(fetchedLocal.length).to.equal(1); + expect(fetchedLocal[0].toJSON()).to.deep.equal(fetchedGrant.toJSON()); + }); + }); + + describe('import()', () => { + it('imports the grant without storing it', async () => { + // alice creates a grant and sends it to her remote + const grant = await aliceDwn.permissions.grant({ + store : false, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + const sent = await grant.send(); + expect(sent.status.code).to.equal(202); + + // bob queries alice's remote for a grant + let fetchedFromAlice = await bobDwn.permissions.queryGrants({ + from: aliceDid.uri, + }); + expect(fetchedFromAlice.length).to.equal(1); + const fetchedGrant = fetchedFromAlice[0]; + + // confirm the grant does not yet exist in bob's remote + let fetchedRemote = await bobDwn.permissions.queryGrants({ + from: bobDid.uri, + }); + expect(fetchedRemote.length).to.equal(0); + + // attempt to send it to bob's remote without importing it first + let sentToBob = await fetchedGrant.send(bobDid.uri); + expect(sentToBob.status.code).to.equal(401); + + // import the grant without storing it + let imported = await fetchedGrant.import(false); + expect(imported.status.code).to.equal(202); + + // fetch from local to ensure it was not stored + const fetchedLocal = await bobDwn.permissions.queryGrants(); + expect(fetchedLocal.length).to.equal(0); + + // send the grant to bob's remote + sentToBob = await fetchedGrant.send(bobDid.uri); + expect(sentToBob.status.code).to.equal(202); + + // fetch from bob's remote to ensure it was imported + fetchedRemote = await bobDwn.permissions.queryGrants({ + from: bobDid.uri, + }); + expect(fetchedRemote.length).to.equal(1); + expect(fetchedRemote[0].toJSON()).to.deep.equal(fetchedGrant.toJSON()); + }); + + it('imports the grant and stores it', async () => { + // alice creates a grant and sends it to her remote + const grant = await aliceDwn.permissions.grant({ + store : false, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + const sent = await grant.send(); + expect(sent.status.code).to.equal(202); + + // bob queries alice's remote for a grant + let fetchedFromAlice = await bobDwn.permissions.queryGrants({ + from: aliceDid.uri, + }); + expect(fetchedFromAlice.length).to.equal(1); + const fetchedGrant = fetchedFromAlice[0]; + + // confirm the grant does not yet exist in bob's remote + let fetchedRemote = await bobDwn.permissions.queryGrants({ + from: bobDid.uri, + }); + expect(fetchedRemote.length).to.equal(0); + + // import the grant and store it + let imported = await fetchedGrant.import(true); + expect(imported.status.code).to.equal(202); + + // fetch from local to ensure it was stored + const fetchedLocal = await bobDwn.permissions.queryGrants(); + expect(fetchedLocal.length).to.equal(1); + expect(fetchedLocal[0].toJSON()).to.deep.equal(fetchedGrant.toJSON()); + }); + }); + + describe('toJSON()', () => { + it('returns the grant as a PermissionsGrant JSON object', async () => { + const { grant, message } = await testHarness.agent.permissions.createGrant({ + store : false, + author : aliceDid.uri, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + const parsedGrant = await PermissionGrant.parse({ + agent : testHarness.agent, + connectedDid : bobDid.uri, + message, + }); + + expect(parsedGrant.toJSON()).to.deep.equal(grant); + }); + }); + + describe('revoke()', () => { + it('revokes the grant, stores it by default', async () => { + // create a grant + const grant = await aliceDwn.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + let isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(false); + + // revoke the grant, stores it by default + const revocation = await grant.revoke(); + expect(revocation.author).to.equal(aliceDid.uri); + + isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(true); + }); + + it('revokes the grant, does not store it', async () => { + // create a grant + const grant = await aliceDwn.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + let isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(false); + + // revoke the grant but do not store it + const revocation = await grant.revoke(false); + expect(revocation.author).to.equal(aliceDid.uri); + + // is still false + isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(false); + + // store the revocation + await revocation.store(); + + // is now true + isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(true); + }); + + it('sends the revocation to a remote target', async () => { + // create a grant + const grant = await aliceDwn.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + // send the grant to alice's remote + const sentGrant = await grant.send(); + expect(sentGrant.status.code).to.equal(202); + + // revoke the grant but do not store it + const revocation = await grant.revoke(false); + const sendToAliceRevoke = await revocation.send(aliceDid.uri); + expect(sendToAliceRevoke.status.code).to.equal(202); + + // should not return revoked since it was not stored locally + let isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(false); + + // check the revocation status of the grant on alice's remote node + isRevoked = await grant.isRevoked(true); + expect(isRevoked).to.equal(true); + }); + }); + + describe('isRevoked()', () => { + it('checks revocation status of local DWN', async () => { + // create a grant + const grant = await aliceDwn.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + let isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(false); + + // revoke the grant + const revocation = await grant.revoke(); + expect(revocation.author).to.equal(aliceDid.uri); + + isRevoked = await grant.isRevoked(); + expect(isRevoked).to.equal(true); + }); + + it('checks revocation status of remote DWN', async () => { + // create a grant + const grant = await aliceDwn.permissions.grant({ + store : true, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + // send the grant to alice's remote + const sentGrant = await grant.send(); + expect(sentGrant.status.code).to.equal(202); + + // revoke the grant but do not store it locally + const revocation = await grant.revoke(false); + expect(revocation.author).to.equal(aliceDid.uri); + + // check the revocation status on alice's remote node first + let isRevoked = await grant.isRevoked(true); + expect(isRevoked).to.equal(false); + + // send the revocation to alice's remote + const sentRevocation = await revocation.send(); + expect(sentRevocation.status.code).to.equal(202); + + // check the revocation status of the grant on alice's remote node + isRevoked = await grant.isRevoked(true); + expect(isRevoked).to.equal(true); + }); + }); +}); \ No newline at end of file diff --git a/packages/api/tests/permission-request.spec.ts b/packages/api/tests/permission-request.spec.ts new file mode 100644 index 000000000..e98d0c487 --- /dev/null +++ b/packages/api/tests/permission-request.spec.ts @@ -0,0 +1,260 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; + +import { testDwnUrl } from './utils/test-config.js'; + +import { BearerDid } from '@web5/dids'; +import { DwnApi } from '../src/dwn-api.js'; +import { PlatformAgentTestHarness } from '@web5/agent'; +import { Web5UserAgent } from '@web5/user-agent'; +import { DwnInterfaceName, DwnMethodName, Time } from '@tbd54566975/dwn-sdk-js'; +import { PermissionRequest } from '../src/permission-request.js'; + +const testDwnUrls = [testDwnUrl]; + +describe('PermissionRequest', () => { + let aliceDid: BearerDid; + let bobDid: BearerDid; + let aliceDwn: DwnApi; + let bobDwn: DwnApi; + let testHarness: PlatformAgentTestHarness; + + before(async () => { + testHarness = await PlatformAgentTestHarness.setup({ + agentClass : Web5UserAgent, + agentStores : 'memory' + }); + + }); + + beforeEach(async () => { + sinon.restore(); + await testHarness.clearStorage(); + await testHarness.createAgentDid(); + + // Create an "alice" Identity to author the DWN messages. + const alice = await testHarness.createIdentity({ name: 'Alice', testDwnUrls }); + await testHarness.agent.identity.manage({ portableIdentity: await alice.export() }); + aliceDid = alice.did; + + // Create a "bob" Identity to author the DWN messages. + const bob = await testHarness.createIdentity({ name: 'Bob', testDwnUrls }); + await testHarness.agent.identity.manage({ portableIdentity: await bob.export() }); + bobDid = bob.did; + + aliceDwn = new DwnApi({ agent: testHarness.agent, connectedDid: aliceDid.uri }); + bobDwn = new DwnApi({ agent: testHarness.agent, connectedDid: bobDid.uri }); + }); + + after(async () => { + sinon.restore(); + await testHarness.clearStorage(); + await testHarness.closeStorage(); + }); + + describe('parse()', () => { + it('should parse a grant request message', async () => { + const { request, message } = await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read + } + }); + + const parsedRequest = await PermissionRequest.parse({ + agent : testHarness.agent, + connectedDid : bobDid.uri, + message + }); + + expect(parsedRequest.toJSON()).to.deep.equal(request); + expect(parsedRequest.rawMessage).to.deep.equal(message); + expect(parsedRequest.id).to.equal(request.id); + expect(parsedRequest.requester).to.equal(request.requester); + expect(parsedRequest.description).to.equal(request.description); + expect(parsedRequest.delegated).to.equal(request.delegated); + expect(parsedRequest.scope).to.deep.equal(request.scope); + expect(parsedRequest.conditions).to.deep.equal(request.conditions); + }); + + //TODO: this should happen in the `dwn-sdk-js` helper + xit('throws for an invalid request'); + }); + + describe('send()', () => { + it('should send a grant request to connectedDID by default', async () => { + const grantRequest = await aliceDwn.permissions.request({ + store : false, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read + } + }); + + // confirm the request is not present on the remote + let requests = await aliceDwn.permissions.queryRequests({ + from: aliceDid.uri + }); + expect(requests).to.have.length(0); + + const sendReply = await grantRequest.send(); + expect(sendReply.status.code).to.equal(202); + + // fetch the requests from the remote + requests = await aliceDwn.permissions.queryRequests({ + from: aliceDid.uri + }); + + expect(requests).to.have.length(1); + expect(requests[0].id).to.deep.equal(grantRequest.id); + }); + + it('sends a grant request to a remote target', async () => { + const grantRequest = await aliceDwn.permissions.request({ + store : false, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read + } + }); + + // confirm the request is not present on the remote + let remoteRequestsBob = await bobDwn.permissions.queryRequests({ + from: bobDid.uri + }); + expect(remoteRequestsBob).to.have.length(0); + + // send from alice to bob's remote DWN + const sendReply = await grantRequest.send(bobDid.uri); + expect(sendReply.status.code).to.equal(202); + + // fetch the requests from the remote + remoteRequestsBob = await bobDwn.permissions.queryRequests({ + from: bobDid.uri + }); + expect(remoteRequestsBob).to.have.length(1); + expect(remoteRequestsBob[0].id).to.deep.equal(grantRequest.id); + }); + }); + + describe('store()', () => { + it('stores the request', async () => { + // create a grant not marked as stored + const request = await aliceDwn.permissions.request({ + store : false, + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + // validate the grant does not exist in the DWN + let fetchedRequests = await bobDwn.permissions.queryRequests({ + from: bobDid.uri, + }); + expect(fetchedRequests.length).to.equal(0); + + const sentToBob = await request.send(bobDid.uri); + expect(sentToBob.status.code).to.equal(202); + + // Bob fetches requests + fetchedRequests = await bobDwn.permissions.queryRequests({ + from: bobDid.uri, + }); + expect(fetchedRequests.length).to.equal(1); + + let localRequests = await bobDwn.permissions.queryRequests(); + expect(localRequests.length).to.equal(0); + + const remoteGrant = fetchedRequests[0]; + + // store the grant + const stored = await remoteGrant.store(); + expect(stored.status.code).to.equal(202); + + // validate the grant now exists in the DWN + localRequests = await bobDwn.permissions.queryRequests(); + expect(localRequests.length).to.equal(1); + expect(localRequests[0].toJSON()).to.deep.equal(request.toJSON()); + }); + }); + + describe('grant()', () => { + it('should create a grant and store it by default', async () => { + const requestFromBob = await bobDwn.permissions.request({ + store : false, + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + const sentToAlice = await requestFromBob.send(aliceDid.uri); + expect(sentToAlice.status.code).to.equal(202); + + // Alice fetches requests + let requests = await aliceDwn.permissions.queryRequests({ + from: aliceDid.uri + }); + expect(requests.length).to.equal(1); + + // confirm no grants exist + let grants = await aliceDwn.permissions.queryGrants(); + expect(grants.length).to.equal(0); + + // Alice grants the request and it will be stored by default + const dateExpires = Time.createOffsetTimestamp({ seconds: 60 }); + const grant = await requests[0].grant(dateExpires); + expect(grant).to.exist; + + // confirm the grant exists + grants = await aliceDwn.permissions.queryGrants(); + expect(grants.length).to.equal(1); + expect(grants[0].id).to.equal(grant.id); + }); + + it('does not store the grant if store is false', async () => { + const requestFromBob = await bobDwn.permissions.request({ + store : false, + scope : { interface: DwnInterfaceName.Messages, method: DwnMethodName.Read } + }); + + const sentToAlice = await requestFromBob.send(aliceDid.uri); + expect(sentToAlice.status.code).to.equal(202); + + // Alice fetches requests + let requests = await aliceDwn.permissions.queryRequests({ + from: aliceDid.uri + }); + expect(requests.length).to.equal(1); + + // confirm no grants exist + let grants = await aliceDwn.permissions.queryGrants(); + expect(grants.length).to.equal(0); + + // Alice grants the request but does not store it + const dateExpires = Time.createOffsetTimestamp({ seconds: 60 }); + const grant = await requests[0].grant(dateExpires, false); + expect(grant).to.exist; + + // confirm the grant does not exist + grants = await aliceDwn.permissions.queryGrants(); + expect(grants.length).to.equal(0); + }); + }); + + describe('toJSON()', () => { + it('should return the PermissionRequest as a JSON object', async () => { + const { request, message } = await testHarness.agent.permissions.createRequest({ + author : aliceDid.uri, + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read + } + }); + + const grantRequest = await PermissionRequest.parse({ + agent : testHarness.agent, + connectedDid : bobDid.uri, + message + }); + + expect(grantRequest.toJSON()).to.deep.equal(request); + }); + }); +}); \ No newline at end of file diff --git a/packages/api/tests/web5.spec.ts b/packages/api/tests/web5.spec.ts index c7c143d86..b91429095 100644 --- a/packages/api/tests/web5.spec.ts +++ b/packages/api/tests/web5.spec.ts @@ -16,6 +16,7 @@ import { Web5 } from '../src/web5.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 api', () => { describe('using Test Harness', () => { @@ -96,9 +97,9 @@ describe('web5 api', () => { }); // 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 : { @@ -108,9 +109,9 @@ describe('web5 api', () => { } }); - 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 : { @@ -120,28 +121,9 @@ describe('web5 api', () => { } }); - // 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(WalletConnect, 'initClient').resolves({ - delegateGrants : [ writeGrant.dataEncodedMessage, readGrant.dataEncodedMessage ], + delegateGrants : [ writeGrant.message, readGrant.message ], delegateDid : await app.export(), connectedDid : alice.did.uri }); @@ -182,24 +164,6 @@ describe('web5 api', () => { }); 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!', @@ -256,9 +220,9 @@ describe('web5 api', () => { } // 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 : { @@ -268,9 +232,9 @@ describe('web5 api', () => { } }); - 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 : { @@ -282,7 +246,11 @@ describe('web5 api', () => { // 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, + delegateDid : app.uri, + }); // attempt to delete using the grant const deleteResult = await web5.dwn.records.delete({ @@ -350,9 +318,9 @@ describe('web5 api', () => { }); // 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 : { @@ -362,9 +330,9 @@ describe('web5 api', () => { } }); - 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 : { @@ -374,28 +342,365 @@ describe('web5 api', () => { } }); - // write the grants to wallet - const { reply: writeGrantReply } = await testHarness.agent.dwn.processRequest({ + // stub the walletInit method of the Connect placeholder class + sinon.stub(WalletConnect, 'initClient').resolves({ + delegateGrants : [ writeGrant.message, readGrant.message ], + delegateDid : await app.export(), + connectedDid : alice.did.uri + }); + + const appTestHarness = await PlatformAgentTestHarness.setup({ + agentClass : Web5UserAgent, + agentStores : 'memory', + testDataLocation : '__TESTDATA__/web5-connect-app' + }); + await appTestHarness.clearStorage(); + await appTestHarness.createAgentDid(); + + + // stub processDwnRequest to return a non 202 error code + sinon.stub(appTestHarness.agent, 'processDwnRequest').resolves({ + messageCid : '', + reply : { status: { code: 400, detail: 'Bad Request' } } + }); + + // stub the create method of the Web5UserAgent to use the test harness agent + sinon.stub(Web5UserAgent, 'create').resolves(appTestHarness.agent as Web5UserAgent); + + try { + // connect to the app, the options don't matter because we're stubbing the initClient method + await Web5.connect({ + walletConnectOptions: { + connectServerUrl : 'https://connect.example.com', + walletUri : 'https://wallet.example.com', + validatePin : async () => { return '1234'; }, + onWalletUriReady : (_walletUri: string) => {}, + permissionRequests : [] + } + }); + + expect.fail('Should have thrown an error'); + } catch(error:any) { + expect(error.message).to.equal('Failed to connect to wallet: AgentDwnApi: Failed to process connected grant: Bad Request'); + } + + // check that the Identity was deleted + const appDid = await appTestHarness.agent.identity.list(); + expect(appDid).to.have.lengthOf(0); + + // close the app test harness storage + await appTestHarness.clearStorage(); + await appTestHarness.closeStorage(); + }); + + it('logs an error if there is a failure during cleanup of Identity information, but does not throw', async () => { + // create a DID that is not stored in the agent + const did = await DidJwk.create(); + const identity = new BearerIdentity({ + did, + metadata: { + name : 'Test', + uri : did.uri, + tenant : did.uri + } + }); + + // stub console.error to avoid logging errors into the test output, use as spy to check if the error message is logged + const consoleSpy = sinon.stub(console, 'error').returns(); + + // call identityCleanup on a did that does not exist + await Web5['cleanUpIdentity']({ userAgent: testHarness.agent as Web5UserAgent, identity }); + + expect(consoleSpy.calledTwice, 'console.error called twice').to.be.true; + }); + + it('uses walletConnectOptions to connect to a DID and import the grants', async () => { + // Create a new Identity. + const alice = await testHarness.createIdentity({ + name : 'Alice', + testDwnUrls : [testDwnUrl] + }); + + // alice installs a protocol definition + const protocol: DwnProtocolDefinition = { + protocol : 'https://example.com/test-protocol', + published : true, + types : { + foo : {}, + bar : {} + }, + structure: { + foo: { + bar: {} + } + } + }; + + const { reply: protocolConfigReply, message: protocolConfigureMessage } = await testHarness.agent.dwn.processRequest({ + author : alice.did.uri, + target : alice.did.uri, + messageType : DwnInterface.ProtocolsConfigure, + messageParams : { + definition: protocol, + }, + }); + expect(protocolConfigReply.status.code).to.equal(202); + + // create an identity for the app to use + const app = await testHarness.agent.did.create({ + store : false, + method : 'jwk', + }); + + // create grants for the app to use + const writeGrant = await testHarness.agent.permissions.createGrant({ + delegated : true, author : alice.did.uri, - target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : writeGrant.recordsWrite.message, - dataStream : new Blob([ writeGrant.permissionGrantBytes ]) + grantedTo : app.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : protocol.protocol, + } }); - expect(writeGrantReply.status.code).to.equal(202); - const { reply: readGrantReply } = await testHarness.agent.dwn.processRequest({ + const readGrant = await testHarness.agent.permissions.createGrant({ + delegated : true, + author : alice.did.uri, + grantedTo : app.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Read, + protocol : protocol.protocol, + } + }); + + // stub the walletInit method + sinon.stub(WalletConnect, 'initClient').resolves({ + delegateGrants : [ writeGrant.message, readGrant.message ], + delegateDid : await app.export(), + connectedDid : alice.did.uri + }); + + const appTestHarness = await PlatformAgentTestHarness.setup({ + agentClass : Web5UserAgent, + agentStores : 'memory', + testDataLocation : '__TESTDATA__/web5-connect-app' + }); + await appTestHarness.clearStorage(); + await appTestHarness.createAgentDid(); + + // stub the create method of the Web5UserAgent to use the test harness agent + sinon.stub(Web5UserAgent, 'create').resolves(appTestHarness.agent as Web5UserAgent); + + // connect to the app, the options don't matter because we're stubbing the initClient method + const { web5, did, delegateDid } = await Web5.connect({ + walletConnectOptions: { + connectServerUrl : 'https://connect.example.com', + walletUri : 'https://wallet.example.com', + validatePin : async () => { return '1234'; }, + onWalletUriReady : (_walletUri: string) => {}, + permissionRequests : [], + } + }); + expect(web5).to.exist; + expect(did).to.exist; + expect(delegateDid).to.exist; + expect(did).to.equal(alice.did.uri); + expect(delegateDid).to.equal(app.uri); + + // in lieu of sync, we will process the grants and protocol definition on the local connected agent + const { reply: localProtocolReply } = await web5.agent.processDwnRequest({ author : alice.did.uri, target : alice.did.uri, - messageType : DwnInterface.RecordsWrite, - rawMessage : readGrant.recordsWrite.message, - dataStream : new Blob([ readGrant.permissionGrantBytes ]) + messageType : DwnInterface.ProtocolsConfigure, + rawMessage : protocolConfigureMessage, + }); + expect(localProtocolReply.status.code).to.equal(202); + + // use the grant to write a record + const writeResult = await web5.dwn.records.write({ + data : 'Hello, world!', + message : { + protocol : protocol.protocol, + protocolPath : 'foo', + } + }); + expect(writeResult.status.code).to.equal(202); + expect(writeResult.record).to.exist; + // test that the logical author is the connected DID and the signer is the impersonator DID + expect(writeResult.record.author).to.equal(did); + const writeSigner = Jws.getSignerDid(writeResult.record.authorization.signature.signatures[0]); + expect(writeSigner).to.equal(delegateDid); + + const readResult = await web5.dwn.records.read({ + protocol : protocol.protocol, + message : { + filter: { recordId: writeResult.record.id } + } + }); + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.exist; + // test that the logical author is the connected DID and the signer is the impersonator DID + expect(readResult.record.author).to.equal(did); + const readSigner = Jws.getSignerDid(readResult.record.authorization.signature.signatures[0]); + expect(readSigner).to.equal(delegateDid); + + // attempt to query or delete, should fail because we did not grant query permissions + try { + await web5.dwn.records.query({ + protocol : protocol.protocol, + message : { + filter: { protocol: protocol.protocol } + } + }); + + expect.fail('Should have thrown an error'); + } catch(error:any) { + expect(error.message).to.include('AgentDwnApi: No permissions found for RecordsQuery'); + } + + try { + await web5.dwn.records.delete({ + protocol : protocol.protocol, + message : { + recordId: writeResult.record.id + } + }); + + expect.fail('Should have thrown an error'); + } catch(error:any) { + expect(error.message).to.include('AgentDwnApi: No permissions found for RecordsDelete'); + } + + // grant query and delete permissions + const queryGrant = await testHarness.agent.permissions.createGrant({ + delegated : true, + author : alice.did.uri, + grantedTo : delegateDid, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Query, + protocol : protocol.protocol, + } + }); + + const deleteGrant = await testHarness.agent.permissions.createGrant({ + delegated : true, + author : alice.did.uri, + grantedTo : delegateDid, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Delete, + protocol : protocol.protocol, + } + }); + + // write the grants to app as owner + // this also clears the grants cache + await DwnApi.processConnectedGrants({ + grants : [ queryGrant.message, deleteGrant.message ], + agent : appTestHarness.agent, + delegateDid, + }); + + // attempt to delete using the grant + const deleteResult = await web5.dwn.records.delete({ + protocol : protocol.protocol, + message : { + recordId: writeResult.record.id + } + }); + expect(deleteResult.status.code).to.equal(202); + + // attempt to query using the grant + const queryResult = await web5.dwn.records.query({ + protocol : protocol.protocol, + message : { + filter: { protocol: protocol.protocol } + } + }); + expect(queryResult.status.code).to.equal(200); + expect(queryResult.records).to.have.lengthOf(0); // record has been deleted + + // connecting a 2nd time will return the same connectedDID and delegatedDID + const { did: did2, delegateDid: delegateDid2 } = await Web5.connect(); + expect(did2).to.equal(did); + expect(delegateDid2).to.equal(delegateDid); + + // Close the app test harness storage. + await appTestHarness.clearStorage(); + await appTestHarness.closeStorage(); + }); + + it('cleans up imported Identity from walletConnectOptions flow if grants cannot be processed', async () => { + const alice = await testHarness.createIdentity({ + name : 'Alice', + testDwnUrls : [testDwnUrl] + }); + + // alice installs a protocol definition + const protocol: DwnProtocolDefinition = { + protocol : 'https://example.com/test-protocol', + published : true, + types : { + foo : {}, + bar : {} + }, + structure: { + foo: { + bar: {} + } + } + }; + + const { reply: protocolConfigReply } = await testHarness.agent.dwn.processRequest({ + author : alice.did.uri, + target : alice.did.uri, + messageType : DwnInterface.ProtocolsConfigure, + messageParams : { + definition: protocol, + }, + }); + expect(protocolConfigReply.status.code).to.equal(202); + // create an identity for the app to use + const app = await testHarness.agent.did.create({ + store : false, + method : 'jwk', + }); + + // create grants for the app to use + const writeGrant = await testHarness.agent.permissions.createGrant({ + delegated : true, + author : alice.did.uri, + grantedTo : app.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : protocol.protocol, + } + }); + + const readGrant = await testHarness.agent.permissions.createGrant({ + delegated : true, + author : alice.did.uri, + grantedTo : app.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Read, + protocol : protocol.protocol, + } }); - expect(readGrantReply.status.code).to.equal(202); // stub the walletInit method of the Connect placeholder class sinon.stub(WalletConnect, 'initClient').resolves({ - delegateGrants : [ writeGrant.dataEncodedMessage, readGrant.dataEncodedMessage ], + delegateGrants : [ writeGrant.message, readGrant.message ], delegateDid : await app.export(), connectedDid : alice.did.uri }); @@ -435,16 +740,12 @@ describe('web5 api', () => { 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(); @@ -701,6 +1002,28 @@ describe('web5 api', () => { expect(did2).to.equal(did); }); + it('defaults to the first identity if multiple identities exist', async () => { + // scenario: For some reason more than one identity exists when attempting to re-connect to `Web5` + // the first identity in the array should be the one selected + // TODO: this has happened due to a race condition somewhere. Dig into this issue and implement a better way to select/manage DIDs when using `Web5.connect()` + + // create an identity by connecting + sinon.stub(Web5UserAgent, 'create').resolves(testHarness.agent as Web5UserAgent); + const { web5, did } = await Web5.connect({ techPreview: { dwnEndpoints: [ testDwnUrl ] }}); + expect(web5).to.exist; + expect(did).to.exist; + + // create a second identity + await testHarness.agent.identity.create({ + didMethod : 'jwk', + metadata : { name: 'Second' } + }); + + // connect again + const { did: did2 } = await Web5.connect(); + expect(did2).to.equal(did); + }); + it('defaults to `https://dwn.tbddev.org/beta` as the single DWN Service endpoint if non is provided', async () => { sinon .stub(Web5UserAgent, 'create') diff --git a/packages/identity-agent/src/identity-agent.ts b/packages/identity-agent/src/identity-agent.ts index 78c642ce6..e16231156 100644 --- a/packages/identity-agent/src/identity-agent.ts +++ b/packages/identity-agent/src/identity-agent.ts @@ -1,4 +1,4 @@ -import type { +import { Web5Rpc, DidRequest, VcResponse, @@ -11,6 +11,7 @@ import type { ProcessVcRequest, ProcessDwnRequest, Web5PlatformAgent, + AgentPermissionsApi, } from '@web5/agent'; import { LevelStore } from '@web5/common'; @@ -77,6 +78,8 @@ export type AgentParams = identityApi: AgentIdentityApi; /** Responsible for securely managing the cryptographic keys of the agent. */ keyManager: TKeyManager; + /** Facilitates fetching, requesting, creating, revoking and validating revocation status of permissions */ + permissionsApi: AgentPermissionsApi; /** Remote procedure call (RPC) client used to communicate with other Web5 services. */ rpcClient: Web5Rpc; /** Facilitates data synchronization of DWN records between nodes. */ @@ -89,6 +92,7 @@ export class Web5IdentityAgent; public keyManager: TKeyManager; + public permissions: AgentPermissionsApi; public rpc: Web5Rpc; public sync: AgentSyncApi; public vault: HdIdentityVault; @@ -102,6 +106,7 @@ export class Web5IdentityAgent = {} ): Promise { @@ -158,6 +164,8 @@ export class Web5IdentityAgent { describe('agentDid', () => { it('throws an error if accessed before the Agent is initialized', async () => { // @ts-expect-error - Initializing with empty object to test error. - const identityAgent = new Web5IdentityAgent({ didApi: {}, dwnApi: {}, identityApi: {}, keyManager: {}, syncApi: {} }); + const identityAgent = new Web5IdentityAgent({ didApi: {}, dwnApi: {}, identityApi: {}, keyManager: {}, syncApi: {}, permissionsApi: {} }); try { identityAgent.agentDid; throw new Error('Expected an error'); diff --git a/packages/proxy-agent/src/proxy-agent.ts b/packages/proxy-agent/src/proxy-agent.ts index be9ca934b..2e8c3f772 100644 --- a/packages/proxy-agent/src/proxy-agent.ts +++ b/packages/proxy-agent/src/proxy-agent.ts @@ -1,4 +1,4 @@ -import type { +import { Web5Rpc, DidRequest, VcResponse, @@ -11,6 +11,7 @@ import type { ProcessVcRequest, ProcessDwnRequest, Web5PlatformAgent, + AgentPermissionsApi, } from '@web5/agent'; import { LevelStore } from '@web5/common'; @@ -77,6 +78,8 @@ export type AgentParams = identityApi: AgentIdentityApi; /** Responsible for securely managing the cryptographic keys of the agent. */ keyManager: TKeyManager; + /** Facilitates fetching, requesting, creating, revoking and validating revocation status of permissions */ + permissionsApi: AgentPermissionsApi; /** Remote procedure call (RPC) client used to communicate with other Web5 services. */ rpcClient: Web5Rpc; /** Facilitates data synchronization of DWN records between nodes. */ @@ -89,6 +92,7 @@ export class Web5ProxyAgent; public keyManager: TKeyManager; + public permissions: AgentPermissionsApi; public rpc: Web5Rpc; public sync: AgentSyncApi; public vault: HdIdentityVault; @@ -102,6 +106,7 @@ export class Web5ProxyAgent = {} ): Promise { @@ -156,6 +162,8 @@ export class Web5ProxyAgent { describe('agentDid', () => { it('throws an error if accessed before the Agent is initialized', async () => { // @ts-expect-error - Initializing with empty object to test error. - const userAgent = new Web5ProxyAgent({ didApi: {}, dwnApi: {}, identityApi: {}, keyManager: {}, syncApi: {} }); + const userAgent = new Web5ProxyAgent({ didApi: {}, dwnApi: {}, identityApi: {}, keyManager: {}, permissionsApi: {}, syncApi: {} }); try { userAgent.agentDid; throw new Error('Expected an error'); diff --git a/packages/user-agent/src/user-agent.ts b/packages/user-agent/src/user-agent.ts index f83f0b072..426a0668d 100644 --- a/packages/user-agent/src/user-agent.ts +++ b/packages/user-agent/src/user-agent.ts @@ -1,4 +1,4 @@ -import type { +import { Web5Rpc, DidRequest, VcResponse, @@ -11,6 +11,7 @@ import type { ProcessVcRequest, ProcessDwnRequest, Web5PlatformAgent, + AgentPermissionsApi, } from '@web5/agent'; import { LevelStore } from '@web5/common'; @@ -77,6 +78,8 @@ export type AgentParams = identityApi: AgentIdentityApi; /** Responsible for securely managing the cryptographic keys of the agent. */ keyManager: TKeyManager; + /** Facilitates fetching, requesting, creating, revoking and validating revocation status of permissions */ + permissionsApi: AgentPermissionsApi; /** Remote procedure call (RPC) client used to communicate with other Web5 services. */ rpcClient: Web5Rpc; /** Facilitates data synchronization of DWN records between nodes. */ @@ -89,6 +92,7 @@ export class Web5UserAgent; public keyManager: TKeyManager; + public permissions: AgentPermissionsApi; public rpc: Web5Rpc; public sync: AgentSyncApi; public vault: HdIdentityVault; @@ -102,6 +106,7 @@ export class Web5UserAgent = {} ): Promise { @@ -158,6 +164,8 @@ export class Web5UserAgent { describe('agentDid', () => { it('throws an error if accessed before the Agent is initialized', async () => { // @ts-expect-error - Initializing with empty object to test error. - const userAgent = new Web5UserAgent({ didApi: {}, dwnApi: {}, identityApi: {}, keyManager: {}, syncApi: {} }); + const userAgent = new Web5UserAgent({ didApi: {}, dwnApi: {}, identityApi: {}, keyManager: {}, permissionsApi: {}, syncApi: {} }); try { userAgent.agentDid; throw new Error('Expected an error');