From e4093216fee5d48838c63437650222a1bdc28358 Mon Sep 17 00:00:00 2001 From: DaevMithran <61043607+DaevMithran@users.noreply.github.com> Date: Thu, 10 Oct 2024 13:44:44 +0530 Subject: [PATCH] feat: Add trust-registry revocation apis [DEV-4437] (#604) * feat: Add trust-registry revocation apis * Add revocation tests * Update swagger * Disable jsonld tests * fix tests * Support credential status in issue * package * Revert "package" This reverts commit c5db117fe4890a99e22cf5e5da9788bc767b7fe9. * Update package-lock.json * Fix typo --------- Co-authored-by: Ankur Banerjee --- package-lock.json | 4 +- src/app.ts | 15 + src/controllers/api/accreditation.ts | 390 ++++++++++++++++-- src/helpers/helpers.ts | 14 + .../auth/routes/api/accreditation-auth.ts | 30 ++ src/services/api/accreditation.ts | 8 +- src/static/swagger-api.json | 233 ++++++++++- src/types/accreditation.ts | 45 +- src/types/constants.ts | 2 +- src/types/credential.ts | 8 +- src/types/swagger-api-types.ts | 47 ++- .../credential/issue-verify-flow.spec.ts | 4 +- .../authorize-jwt-revocation.json | 18 + .../accreditation/revocation-flow.spec.ts | 86 ++++ .../accreditation/suspension-flow.spec.ts | 119 ++++++ 15 files changed, 961 insertions(+), 62 deletions(-) create mode 100644 tests/e2e/payloads/accreditation/authorize-jwt-revocation.json create mode 100644 tests/e2e/sequential/accreditation/revocation-flow.spec.ts create mode 100644 tests/e2e/sequential/accreditation/suspension-flow.spec.ts diff --git a/package-lock.json b/package-lock.json index 9278debd..94807157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cheqd/studio", - "version": "3.3.0", + "version": "3.4.0-develop.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cheqd/studio", - "version": "3.3.0", + "version": "3.4.0-develop.1", "license": "Apache-2.0", "dependencies": { "@cheqd/did-provider-cheqd": "^4.2.0", diff --git a/src/app.ts b/src/app.ts index 8b91a486..b356f10f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -204,6 +204,21 @@ class App { AccreditationController.verifyValidator, new AccreditationController().verify ); + app.post( + '/trust-registry/accreditation/revoke', + AccreditationController.publishValidator, + new AccreditationController().revoke + ); + app.post( + '/trust-registry/accreditation/suspend', + AccreditationController.publishValidator, + new AccreditationController().suspend + ); + app.post( + '/trust-registry/accreditation/reinstate', + AccreditationController.publishValidator, + new AccreditationController().reinstate + ); // Resource API app.post( diff --git a/src/controllers/api/accreditation.ts b/src/controllers/api/accreditation.ts index 7b36126f..22e60c2f 100644 --- a/src/controllers/api/accreditation.ts +++ b/src/controllers/api/accreditation.ts @@ -3,19 +3,22 @@ import type { VerifiableCredential } from '@veramo/core'; import type { DIDAccreditationRequestBody, DIDAccreditationRequestParams, + RevokeAccreditationRequestBody, + RevokeAccreditationRequestQuery, + RevokeAccreditationResponseBody, + SuspendAccreditationRequestBody, + SuspendAccreditationRequestQuery, + SuspendAccreditationResponseBody, + UnsuspendAccreditationRequestBody, + UnsuspendAccreditationRequestQuery, + UnsuspendAccreditationResponseBody, VerifyAccreditationRequestBody, } from '../../types/accreditation.js'; -import type { ICredentialTrack, ITrackOperation } from '../../types/track.js'; -import type { CredentialRequest } from '../../types/credential.js'; +import type { ICredentialStatusTrack, ICredentialTrack, ITrackOperation } from '../../types/track.js'; +import type { CredentialRequest, UnsuccesfulRevokeCredentialResponseBody } from '../../types/credential.js'; import { StatusCodes } from 'http-status-codes'; import { v4 } from 'uuid'; -import { - AccreditationRequestType, - DIDAccreditationTypes, - isDidAndResourceId, - isDidAndResourceName, - isDidUrl, -} from '../../types/accreditation.js'; +import { AccreditationRequestType, DIDAccreditationTypes } from '../../types/accreditation.js'; import { CredentialConnectors, VerifyCredentialRequestQuery } from '../../types/credential.js'; import { OperationCategoryNameEnum, OperationNameEnum } from '../../types/constants.js'; import { IdentityServiceStrategySetup } from '../../services/identity/index.js'; @@ -24,7 +27,8 @@ import { Credentials } from '../../services/api/credentials.js'; import { eventTracker } from '../../services/track/tracker.js'; import { body, query } from '../validator/index.js'; import { validate } from '../validator/decorator.js'; -import { parseDidFromDidUrl } from '../../helpers/helpers.js'; +import { constructDidUrl, parseDidFromDidUrl } from '../../helpers/helpers.js'; +import { CheqdW3CVerifiableCredential } from '../../services/w3c-credential.js'; export class AccreditationController { public static issueValidator = [ @@ -36,15 +40,18 @@ export class AccreditationController { AccreditationRequestType.attest, ]) .bail(), - body('issuerDid').exists().isString().isDID().bail(), - body('subjectDid').exists().isString().isDID().bail(), + body('accreditationName').exists().isString().withMessage('accreditationName is required').bail(), + body('issuerDid').exists().isDID().bail(), + body('subjectDid').exists().isDID().bail(), body('schemas').exists().isArray().withMessage('schemas must be a array').bail(), body('schemas.*.url').isString().withMessage('schema urls must be a string').bail(), body('schemas.*.type') .custom((value) => typeof value === 'string' || (Array.isArray(value) && typeof value[0] === 'string')) .withMessage('schema type must be a string'), - body('parentAccreditation').optional().bail(), - body('rootAuthorization').optional().bail(), + body('parentAccreditation').optional().isString().withMessage('parentAccreditation must be a string').bail(), + body('rootAuthorization').optional().isString().withMessage('rootAuthorization must be a string').bail(), + body('trustFramework').optional().isString().withMessage('trustFramework must be a string').bail(), + body('trustFrameworkId').optional().isString().withMessage('trustFrameworkId must be a string').bail(), query('accreditationType') .custom((value, { req }) => { const { parentAccreditation, rootAuthorization, trustFramework, trustFrameworkId } = req.body; @@ -122,6 +129,10 @@ export class AccreditationController { query('policies').optional().isObject().withMessage('Verification policies should be an object').bail(), ]; + public static publishValidator = [ + query('publish').optional().isBoolean().withMessage('publish should be a boolean value').toBoolean().bail(), + ]; + /** * @openapi * @@ -191,6 +202,7 @@ export class AccreditationController { attributes, accreditationName, format, + credentialStatus, } = request.body as DIDAccreditationRequestBody; try { @@ -242,6 +254,7 @@ export class AccreditationController { connector: CredentialConnectors.Resource, // resource connector credentialId: resourceId, credentialName: accreditationName, + credentialStatus, }; let resourceType: string; @@ -381,18 +394,8 @@ export class AccreditationController { const { policies, subjectDid, schemas } = request.body as VerifyAccreditationRequestBody; // construct didUrl - let didUrl: string; - let did: string; - if (isDidUrl(request.body)) { - didUrl = request.body.didUrl; - did = parseDidFromDidUrl(didUrl); - } else if (isDidAndResourceId(request.body)) { - did = request.body.did; - didUrl = `${did}/resources/${request.body.resourceId}`; - } else if (isDidAndResourceName(request.body)) { - did = request.body.did; - didUrl = `${did}?resourceName=${request.body.resourceName}&resourceType=${request.body.resourceType}`; - } else { + const didUrl = constructDidUrl(request.body); + if (!didUrl) { return response.status(400).json({ error: `Invalid Request: Either didUrl or did with resource attributes are required`, }); @@ -421,7 +424,7 @@ export class AccreditationController { customer: response.locals.customer, user: response.locals.user, data: { - did, + did: parseDidFromDidUrl(didUrl), } satisfies ICredentialTrack, } as ITrackOperation; @@ -437,4 +440,337 @@ export class AccreditationController { }); } } + + /** + * @openapi + * + * /trust-registry/accreditation/revoke: + * post: + * tags: [ Trust Registry ] + * summary: Revoke a Verifiable Accreditation. + * description: This endpoint revokes a given Verifiable Accreditation. As input, it can take the didUrl as a string. The StatusList2021 resource should already be setup in the VC and `credentialStatus` property present in the VC. + * operationId: accredit-revoke + * parameters: + * - in: query + * name: publish + * description: Set whether the StatusList2021 resource should be published to the ledger or not. If set to `false`, the StatusList2021 publisher should manually publish the resource. + * required: true + * schema: + * type: boolean + * default: true + * requestBody: + * content: + * application/x-www-form-urlencoded: + * schema: + * $ref: '#/components/schemas/AccreditationRevokeRequest' + * application/json: + * schema: + * $ref: '#/components/schemas/AccreditationRevokeRequest' + * responses: + * 200: + * description: The request was successful. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RevocationResult' + * 400: + * $ref: '#/components/schemas/InvalidRequest' + * 401: + * $ref: '#/components/schemas/UnauthorizedError' + * 500: + * $ref: '#/components/schemas/InternalError' + */ + @validate + public async revoke(request: Request, response: Response) { + // Get publish flag + const { publish } = request.query as RevokeAccreditationRequestQuery; + // Get symmetric key + const { symmetricKey, ...didUrlParams } = request.body as RevokeAccreditationRequestBody; + // Get strategy e.g. postgres or local + const identityServiceStrategySetup = new IdentityServiceStrategySetup(response.locals.customer.customerId); + + const didUrl = constructDidUrl(didUrlParams); + if (!didUrl) { + return response.status(400).json({ + error: `Invalid Request: Either didUrl or did with resource attributes are required`, + }); + } + + try { + const res = await identityServiceStrategySetup.agent.resolve(didUrl); + + const resource = await res.json(); + + if (resource.dereferencingMetadata) { + return { + success: false, + status: 404, + error: `DID URL ${didUrl} is not found`, + }; + } + + const accreditation: CheqdW3CVerifiableCredential = resource; + + const result = await identityServiceStrategySetup.agent.revokeCredentials( + accreditation, + publish as boolean, + response.locals.customer, + symmetricKey as string + ); + + // Track operation if revocation was successful and publish is true + // Otherwise the StatusList2021 publisher should manually publish the resource + // and it will be tracked there + if (!result.error && result.resourceMetadata && publish) { + // get issuer did + const issuerDid = + typeof accreditation.issuer === 'string' + ? accreditation.issuer + : (accreditation.issuer as { id: string }).id; + const trackInfo = { + category: OperationCategoryNameEnum.CREDENTIAL, + name: OperationNameEnum.CREDENTIAL_REVOKE, + customer: response.locals.customer, + user: response.locals.user, + data: { + did: issuerDid, + encrypted: result.statusList?.metadata?.encrypted, + resource: result.resourceMetadata, + symmetricKey: '', + } satisfies ICredentialStatusTrack, + } as ITrackOperation; + + // Track operation + eventTracker.emit('track', trackInfo); + } + // Return Ok response + return response.status(StatusCodes.OK).json(result satisfies RevokeAccreditationResponseBody); + } catch (error) { + return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: `Internal error: ${(error as Error)?.message || error}`, + }); + } + } + + /** + * @openapi + * + * /trust-registry/accreditation/suspend: + * post: + * tags: [ Trust Registry ] + * summary: Suspend a Verifiable Accreditation. + * description: This endpoint suspends a given Verifiable Accreditation. As input, it can take the didUrl as a string. The StatusList2021 resource should already be setup in the VC and `credentialStatus` property present in the VC. + * operationId: accredit-suspend + * parameters: + * - in: query + * name: publish + * description: Set whether the StatusList2021 resource should be published to the ledger or not. If set to `false`, the StatusList2021 publisher should manually publish the resource. + * required: true + * schema: + * type: boolean + * default: true + * requestBody: + * content: + * application/x-www-form-urlencoded: + * schema: + * $ref: '#/components/schemas/AccreditationRevokeRequest' + * application/json: + * schema: + * $ref: '#/components/schemas/AccreditationRevokeRequest' + * responses: + * 200: + * description: The request was successful. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RevocationResult' + * 400: + * $ref: '#/components/schemas/InvalidRequest' + * 401: + * $ref: '#/components/schemas/UnauthorizedError' + * 500: + * $ref: '#/components/schemas/InternalError' + */ + @validate + public async suspend(request: Request, response: Response) { + // Get publish flag + const { publish } = request.query as SuspendAccreditationRequestQuery; + // Get symmetric key + const { symmetricKey, ...didUrlParams } = request.body as SuspendAccreditationRequestBody; + // Get strategy e.g. postgres or local + const identityServiceStrategySetup = new IdentityServiceStrategySetup(response.locals.customer.customerId); + + const didUrl = constructDidUrl(didUrlParams); + if (!didUrl) { + return response.status(400).json({ + error: `Invalid Request: Either didUrl or did with resource attributes are required`, + }); + } + + try { + const res = await identityServiceStrategySetup.agent.resolve(didUrl); + + const resource = await res.json(); + + if (resource.dereferencingMetadata) { + return { + success: false, + status: 404, + error: `DID URL ${didUrl} is not found`, + }; + } + + const accreditation: CheqdW3CVerifiableCredential = resource; + + const result = await identityServiceStrategySetup.agent.suspendCredentials( + accreditation, + publish as boolean, + response.locals.customer, + symmetricKey as string + ); + + // Track operation if revocation was successful and publish is true + // Otherwise the StatusList2021 publisher should manually publish the resource + // and it will be tracked there + if (!result.error && result.resourceMetadata && publish) { + // get issuer did + const issuerDid = + typeof accreditation.issuer === 'string' + ? accreditation.issuer + : (accreditation.issuer as { id: string }).id; + const trackInfo = { + category: OperationCategoryNameEnum.CREDENTIAL, + name: OperationNameEnum.CREDENTIAL_SUSPEND, + customer: response.locals.customer, + user: response.locals.user, + data: { + did: issuerDid, + encrypted: result.statusList?.metadata?.encrypted, + resource: result.resourceMetadata, + symmetricKey: '', + }, + } as ITrackOperation; + + // Track operation + eventTracker.emit('track', trackInfo); + } + // Return Ok response + return response.status(StatusCodes.OK).json(result satisfies SuspendAccreditationResponseBody); + } catch (error) { + return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: `Internal error: ${(error as Error)?.message || error}`, + }); + } + } + + /** + * @openapi + * + * /trust-registry/accreditation/reinstate: + * post: + * tags: [ Trust Registry ] + * summary: Reinstate a Verifiable Accreditation. + * description: This endpoint reinstates a given Verifiable Accreditation. As input, it can take the didUrl as a string. The StatusList2021 resource should already be setup in the VC and `credentialStatus` property present in the VC. + * operationId: accredit-reinstate + * parameters: + * - in: query + * name: publish + * description: Set whether the StatusList2021 resource should be published to the ledger or not. If set to `false`, the StatusList2021 publisher should manually publish the resource. + * required: true + * schema: + * type: boolean + * default: true + * requestBody: + * content: + * application/x-www-form-urlencoded: + * schema: + * $ref: '#/components/schemas/AccreditationRevokeRequest' + * application/json: + * schema: + * $ref: '#/components/schemas/AccreditationRevokeRequest' + * responses: + * 200: + * description: The request was successful. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RevocationResult' + * 400: + * $ref: '#/components/schemas/InvalidRequest' + * 401: + * $ref: '#/components/schemas/UnauthorizedError' + * 500: + * $ref: '#/components/schemas/InternalError' + */ + @validate + public async reinstate(request: Request, response: Response) { + // Get publish flag + const { publish } = request.query as UnsuspendAccreditationRequestQuery; + // Get symmetric key + const { symmetricKey, ...didUrlParams } = request.body as UnsuspendAccreditationRequestBody; + // Get strategy e.g. postgres or local + const identityServiceStrategySetup = new IdentityServiceStrategySetup(response.locals.customer.customerId); + + const didUrl = constructDidUrl(didUrlParams); + if (!didUrl) { + return response.status(400).json({ + error: `Invalid Request: Either didUrl or did with resource attributes are required`, + }); + } + + try { + const res = await identityServiceStrategySetup.agent.resolve(didUrl); + + const resource = await res.json(); + + if (resource.dereferencingMetadata) { + return { + success: false, + status: 404, + error: `DID URL ${didUrl} is not found`, + }; + } + + const accreditation: CheqdW3CVerifiableCredential = resource; + + const result = await identityServiceStrategySetup.agent.reinstateCredentials( + accreditation, + publish as boolean, + response.locals.customer, + symmetricKey as string + ); + + // Track operation if revocation was successful and publish is true + // Otherwise the StatusList2021 publisher should manually publish the resource + // and it will be tracked there + if (!result.error && result.resourceMetadata && publish) { + // get issuer did + const issuerDid = + typeof accreditation.issuer === 'string' + ? accreditation.issuer + : (accreditation.issuer as { id: string }).id; + const trackInfo = { + category: OperationCategoryNameEnum.CREDENTIAL, + name: OperationNameEnum.CREDENTIAL_UNSUSPEND, + customer: response.locals.customer, + user: response.locals.user, + data: { + did: issuerDid, + encrypted: result.statusList?.metadata?.encrypted || false, + resource: result.resourceMetadata, + symmetricKey: '', + } satisfies ICredentialStatusTrack, + } as ITrackOperation; + + // Track operation + eventTracker.emit('track', trackInfo); + } + // Return Ok response + return response.status(StatusCodes.OK).json(result satisfies UnsuspendAccreditationResponseBody); + } catch (error) { + return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: `Internal error: ${(error as Error)?.message || error}`, + } satisfies UnsuccesfulRevokeCredentialResponseBody); + } + } } diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 461b30e2..cce2a7a0 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -18,6 +18,7 @@ import { LitCompatibleCosmosChains, type DkgOptions, LitNetworks } from '@cheqd/ import { fromString } from 'uint8arrays'; import { config } from 'dotenv'; +import { DIDUrlParams, isDidAndResourceId, isDidAndResourceName, isDidUrl } from '../types/accreditation.js'; config(); @@ -241,3 +242,16 @@ export async function decryptPrivateKey(encryptedPrivateKeyHex: string, ivHex: s export function parseDidFromDidUrl(didUrl: string) { return didUrl.includes('?') ? didUrl.split('?')[0] : didUrl.split('/')[0]; } + +export function constructDidUrl(data: DIDUrlParams) { + let didUrl: string | undefined = undefined; + if (isDidUrl(data)) { + didUrl = data.didUrl; + } else if (isDidAndResourceId(data)) { + didUrl = `${data.did}/resources/${data.resourceId}`; + } else if (isDidAndResourceName(data)) { + didUrl = `${data.did}?resourceName=${data.resourceName}&resourceType=${data.resourceType}`; + } + + return didUrl; +} diff --git a/src/middleware/auth/routes/api/accreditation-auth.ts b/src/middleware/auth/routes/api/accreditation-auth.ts index b1dfbe72..2ae29499 100644 --- a/src/middleware/auth/routes/api/accreditation-auth.ts +++ b/src/middleware/auth/routes/api/accreditation-auth.ts @@ -21,5 +21,35 @@ export class AccreditationAuthRuleProvider extends AuthRuleProvider { skipNamespace: true, } ); + this.registerRule( + '/trust-registry/accreditation/revoke', + 'POST', + 'revoke-accreditation:trust-registry:testnet' + ); + this.registerRule( + '/trust-registry/accreditation/revoke', + 'POST', + 'revoke-accreditation:trust-registry:mainnet' + ); + this.registerRule( + '/trust-registry/accreditation/suspend', + 'POST', + 'suspend-accreditation:trust-registry:testnet' + ); + this.registerRule( + '/trust-registry/accreditation/suspend', + 'POST', + 'suspend-accreditation:trust-registry:mainnet' + ); + this.registerRule( + '/trust-registry/accreditation/reinstate', + 'POST', + 'reinstate-accreditation:trust-registry:testnet' + ); + this.registerRule( + '/trust-registry/accreditation/reinstate', + 'POST', + 'reinstate-accreditation:trust-registry:mainnet' + ); } } diff --git a/src/services/api/accreditation.ts b/src/services/api/accreditation.ts index 6ecc6db0..c1685b5a 100644 --- a/src/services/api/accreditation.ts +++ b/src/services/api/accreditation.ts @@ -34,7 +34,7 @@ export class AccreditationService { return { success: false, status: StatusCodes.NOT_FOUND, - error: `DID Url ${accreditationUrl} is not found`, + error: `DID URL ${accreditationUrl} is not found`, }; } @@ -76,7 +76,7 @@ export class AccreditationService { return { success: false, status: StatusCodes.UNAUTHORIZED, - error: `Invalid Request: Accreditation does not have the permissions for the given schema`, + error: `Accreditation does not have the permissions for the given schema`, }; } @@ -101,7 +101,7 @@ export class AccreditationService { return { success: false, status: StatusCodes.BAD_REQUEST, - error: `invalid accreditation type`, + error: `Invalid accreditation type`, }; } @@ -112,7 +112,7 @@ export class AccreditationService { return { success: false, status: StatusCodes.BAD_REQUEST, - error: `invalid accreditation type`, + error: `Invalid accreditation type`, }; } diff --git a/src/static/swagger-api.json b/src/static/swagger-api.json index 4b2167f4..94bfd102 100644 --- a/src/static/swagger-api.json +++ b/src/static/swagger-api.json @@ -534,6 +534,10 @@ "$ref": "#/components/schemas/SchemaUrl" } }, + "accreditationName": { + "description": "Unique name of the Verifiable Accreditation.", + "type": "string" + }, "attributes": { "description": "JSON object containing the attributes to be included in the Accreditation.", "type": "object" @@ -550,11 +554,19 @@ ] }, "parentAccreditation": { - "description": "DID Url of the parent Verifiable Accreditation.", + "description": "DID URL of the parent Verifiable Accreditation, required for accredit/attest operation.", "type": "string" }, "rootAuthorization": { - "description": "DID Url of the root Verifiable Accreditation.", + "description": "DID URL of the root Verifiable Accreditation, required for accredit/attest operation.", + "type": "string" + }, + "trustFramework": { + "description": "Name or Type of the Trust Framework, required for authorize operation.", + "type": "string" + }, + "trustFrameworkId": { + "description": "Url of the Trust Framework, required for authorize operation.", "type": "string" }, "type": { @@ -684,24 +696,24 @@ "required": [ "issuerDid", "subjectDid", - "schemas" + "schemas", + "accreditationName" ], "example": { "issuerDid": "did:cheqd:testnet:7bf81a20-633c-4cc7-bc4a-5a45801005e0", - "subjectDid": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "subjectDid": "did:cheqd:testnet:2582fe17-9b25-45e4-8104-1cfca430f0c3", "schemas": [ { "type": "MuseumPassCredential", "url": "https://resolver.cheqd.net/1.0/identifiers/did:cheqd:testnet:0a5b94d0-a417-48ed-a6f5-4abc9e95888d?resourceName=MuseumPassCredentialSchema&resourceType=JsonSchemaValidator2018" } ], - "@context": [ - "https://schema.org" - ], - "type": [ - "Person" - ], "format": "jwt", + "accreditationName": "authorizeAccreditation", + "trustFramework": "https://learn.cheqd.io/governance/start", + "trustFrameworkId": "cheqd Governance Framework", + "parentAccreditation": "did:cheqd:testnet:15b74787-6e48-4fd5-8020-eab24e990578?resourceName=accreditAccreditation&resourceType=VerifiableAccreditationToAccredit", + "rootAuthorization": "did:cheqd:testnet:5RpEg66jhhbmASWPXJRWrA?resourceName=authorizeAccreditation&resourceType=VerifiableAuthorisationForTrustChain", "credentialStatus": { "statusPurpose": "revocation", "statusListName": "employee-credentials", @@ -771,6 +783,36 @@ "subjectDid" ] }, + "AccreditationRevokeRequest": { + "type": "object", + "properties": { + "didUrl": { + "description": "Verifiable Accreditation to be verified as a VC-JWT string or a JSON object.", + "type": "string", + "example": "did:cheqd:testnet:7c2b990c-3d05-4ebf-91af-f4f4d0091d2e?resourceName=cheqd-issuer-logo&resourceType=CredentialArtwork" + }, + "did": { + "type": "string", + "example": "did:cheqd:testnet:7c2b990c-3d05-4ebf-91af-f4f4d0091d2e" + }, + "resourceId": { + "type": "string", + "example": "398cee0a-efac-4643-9f4c-74c48c72a14b" + }, + "resourceName": { + "type": "string", + "example": "cheqd-issuer-logo" + }, + "resourceType": { + "type": "string", + "example": "CredentialArtwork" + }, + "symmetricKey": { + "description": "The symmetric key used to encrypt the StatusList2021 DID-Linked Resource. Required if the StatusList2021 DID-Linked Resource is encrypted.", + "type": "string" + } + } + }, "PresentationCreateRequest": { "type": "object", "required": [ @@ -2588,6 +2630,177 @@ } } }, + "/trust-registry/accreditation/revoke": { + "post": { + "tags": [ + "Trust Registry" + ], + "summary": "Revoke a Verifiable Accreditation.", + "description": "This endpoint revokes a given Verifiable Accreditation. As input, it can take the didUrl as a string. The StatusList2021 resource should already be setup in the VC and `credentialStatus` property present in the VC.", + "operationId": "accredit-revoke", + "parameters": [ + { + "in": "query", + "name": "publish", + "description": "Set whether the StatusList2021 resource should be published to the ledger or not. If set to `false`, the StatusList2021 publisher should manually publish the resource.", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccreditationRevokeRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccreditationRevokeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The request was successful.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevocationResult" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/InvalidRequest" + }, + "401": { + "$ref": "#/components/schemas/UnauthorizedError" + }, + "500": { + "$ref": "#/components/schemas/InternalError" + } + } + } + }, + "/trust-registry/accreditation/suspend": { + "post": { + "tags": [ + "Trust Registry" + ], + "summary": "Suspend a Verifiable Accreditation.", + "description": "This endpoint suspends a given Verifiable Accreditation. As input, it can take the didUrl as a string. The StatusList2021 resource should already be setup in the VC and `credentialStatus` property present in the VC.", + "operationId": "accredit-suspend", + "parameters": [ + { + "in": "query", + "name": "publish", + "description": "Set whether the StatusList2021 resource should be published to the ledger or not. If set to `false`, the StatusList2021 publisher should manually publish the resource.", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccreditationRevokeRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccreditationRevokeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The request was successful.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevocationResult" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/InvalidRequest" + }, + "401": { + "$ref": "#/components/schemas/UnauthorizedError" + }, + "500": { + "$ref": "#/components/schemas/InternalError" + } + } + } + }, + "/trust-registry/accreditation/reinstate": { + "post": { + "tags": [ + "Trust Registry" + ], + "summary": "Reinstate a Verifiable Accreditation.", + "description": "This endpoint reinstates a given Verifiable Accreditation. As input, it can take the didUrl as a string. The StatusList2021 resource should already be setup in the VC and `credentialStatus` property present in the VC.", + "operationId": "accredit-reinstate", + "parameters": [ + { + "in": "query", + "name": "publish", + "description": "Set whether the StatusList2021 resource should be published to the ledger or not. If set to `false`, the StatusList2021 publisher should manually publish the resource.", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccreditationRevokeRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccreditationRevokeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The request was successful.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevocationResult" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/InvalidRequest" + }, + "401": { + "$ref": "#/components/schemas/UnauthorizedError" + }, + "500": { + "$ref": "#/components/schemas/InternalError" + } + } + } + }, "/credential-status/create/unencrypted": { "post": { "tags": [ diff --git a/src/types/accreditation.ts b/src/types/accreditation.ts index 780eb315..d025306e 100644 --- a/src/types/accreditation.ts +++ b/src/types/accreditation.ts @@ -1,5 +1,13 @@ import { VerifiableCredential } from '@veramo/core'; -import type { CredentialRequest, VerifyCredentialRequestBody } from './credential'; +import type { CredentialRequest, PublishRequest, VerifyCredentialRequestBody } from './credential'; +import { + BulkRevocationResult, + BulkSuspensionResult, + BulkUnsuspensionResult, + RevocationResult, + SuspensionResult, + UnsuspensionResult, +} from '@cheqd/did-provider-cheqd/build/types'; // Enums export enum DIDAccreditationTypes { @@ -42,12 +50,15 @@ export type DIDAccreditationRequestParams = { accreditationType: 'authorize' | 'accredit' | 'attest'; }; -export interface VerifyAccreditationRequestBody extends Pick { +export interface DIDUrlParams { didUrl?: string; did?: string; resourceId?: string; resourceName?: string; resourceType?: string; +} + +export interface VerifyAccreditationRequestBody extends Pick, DIDUrlParams { subjectDid: string; schemas?: SchemaUrlType[]; } @@ -67,15 +78,15 @@ type DidResourceNameAndType = Pick { expect(response.status()).toBe(StatusCodes.BAD_REQUEST); }); -test(' Issue a jsonLD credential with Ed25519VerificationKey2018', async ({ request }) => { +test.skip(' Issue a jsonLD credential with Ed25519VerificationKey2018', async ({ request }) => { const credentialData = JSON.parse( fs.readFileSync(`${PAYLOADS_PATH.CREDENTIAL}/credential-issue-jsonld-ed25519-2018.json`, 'utf-8') ); @@ -98,7 +98,7 @@ test(' Issue a jsonLD credential with Ed25519VerificationKey2018', async ({ requ expect(result.verified).toBe(true); }); -test(' Issue a jsonLD credential with Ed25519VerificationKey2020', async ({ request }) => { +test.skip(' Issue a jsonLD credential with Ed25519VerificationKey2020', async ({ request }) => { const credentialData = JSON.parse( fs.readFileSync(`${PAYLOADS_PATH.CREDENTIAL}/credential-issue-jsonld-ed25519-2020.json`, 'utf-8') ); diff --git a/tests/e2e/payloads/accreditation/authorize-jwt-revocation.json b/tests/e2e/payloads/accreditation/authorize-jwt-revocation.json new file mode 100644 index 00000000..88615f28 --- /dev/null +++ b/tests/e2e/payloads/accreditation/authorize-jwt-revocation.json @@ -0,0 +1,18 @@ +{ + "issuerDid": "did:cheqd:testnet:5RpEg66jhhbmASWPXJRWrA", + "subjectDid": "did:cheqd:testnet:15b74787-6e48-4fd5-8020-eab24e990578", + "format": "jwt", + "credentialStatus": { + "statusPurpose": "revocation", + "statusListName": "testingstatuslist" + }, + "schemas": [ + { + "url": "https://schema.org/Learn", + "type": "Learn" + } + ], + "accreditationName": "revocationAccreditation", + "trustFrameworkId": "https://learn.cheqd.io/governance/start", + "trustFramework": "cheqd Governance Framework" +} diff --git a/tests/e2e/sequential/accreditation/revocation-flow.spec.ts b/tests/e2e/sequential/accreditation/revocation-flow.spec.ts new file mode 100644 index 00000000..47c9b4f7 --- /dev/null +++ b/tests/e2e/sequential/accreditation/revocation-flow.spec.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs'; +import { test, expect } from '@playwright/test'; +import { StatusCodes } from 'http-status-codes'; +import { CONTENT_TYPE, PAYLOADS_PATH } from '../../constants'; + +test.use({ storageState: 'playwright/.auth/user.json' }); + +const didUrl: string = `did:cheqd:testnet:5RpEg66jhhbmASWPXJRWrA?resourceName=revocationAccreditation&resourceType=VerifiableAuthorisationForTrustChain`; +const subjectDid: string = 'did:cheqd:testnet:15b74787-6e48-4fd5-8020-eab24e990578'; +test(' Issue an Accreditation with revocation statuslist', async ({ request }) => { + const payload = JSON.parse( + fs.readFileSync(`${PAYLOADS_PATH.ACCREDITATION}/authorize-jwt-revocation.json`, 'utf-8') + ); + const issueResponse = await request.post(`/trust-registry/accreditation/issue?accreditationType=authorize`, { + data: JSON.stringify(payload), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const { didUrls, accreditation } = await issueResponse.json(); + expect(didUrls).toContain(didUrl); + expect(issueResponse).toBeOK(); + expect(issueResponse.status()).toBe(StatusCodes.OK); + expect(accreditation.proof.type).toBe('JwtProof2020'); + expect(accreditation.proof).toHaveProperty('jwt'); + expect(typeof accreditation.issuer === 'string' ? accreditation.issuer : accreditation.issuer.id).toBe( + payload.issuerDid + ); + expect(accreditation.type).toContain('VerifiableCredential'); + expect(accreditation.credentialSubject.id).toBe(payload.subjectDid); + expect(accreditation.credentialStatus).toMatchObject({ + type: 'StatusList2021Entry', + statusPurpose: 'revocation', + }); + expect(accreditation.credentialStatus).toHaveProperty('statusListIndex'); + expect(accreditation.credentialStatus).toHaveProperty('id'); +}); + +test(" Verify a Accreditation's revocation status", async ({ request }) => { + const response = await request.post(`/trust-registry/accreditation/verify?verifyStatus=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const result = await response.json(); + expect(response).toBeOK(); + expect(response.status()).toBe(StatusCodes.OK); + expect(result.verified).toBe(true); + expect(result.revoked).toBe(false); +}); + +test(' Verify a Accreditation status after revocation', async ({ request }) => { + const response = await request.post(`/trust-registry/accreditation/revoke?publish=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const result = await response.json(); + expect(response).toBeOK(); + expect(response.status()).toBe(StatusCodes.OK); + expect(result.revoked).toBe(true); + expect(result.published).toBe(true); + + const verificationResponse = await request.post(`/trust-registry/accreditation/verify?verifyStatus=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const verificationResult = await verificationResponse.json(); + expect(verificationResponse).toBeOK(); + expect(verificationResponse.status()).toBe(StatusCodes.OK); + expect(verificationResult.verified).toBe(true); + expect(verificationResult.revoked).toBe(true); +}); diff --git a/tests/e2e/sequential/accreditation/suspension-flow.spec.ts b/tests/e2e/sequential/accreditation/suspension-flow.spec.ts new file mode 100644 index 00000000..aa8ea188 --- /dev/null +++ b/tests/e2e/sequential/accreditation/suspension-flow.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@playwright/test'; +import { StatusCodes } from 'http-status-codes'; +import * as fs from 'fs'; +import { CONTENT_TYPE, PAYLOADS_PATH } from '../../constants'; + +test.use({ storageState: 'playwright/.auth/user.json' }); + +const didUrl: string = `did:cheqd:testnet:5RpEg66jhhbmASWPXJRWrA?resourceName=suspensionAccreditation&resourceType=VerifiableAuthorisationForTrustChain`; +const subjectDid: string = 'did:cheqd:testnet:15b74787-6e48-4fd5-8020-eab24e990578'; + +test(' Issue a jwt accreditation with suspension statuslist', async ({ request }) => { + const payload = JSON.parse( + fs.readFileSync(`${PAYLOADS_PATH.ACCREDITATION}/authorize-jwt-revocation.json`, 'utf-8') + ); + payload.credentialStatus.statusPurpose = 'suspension'; + payload.accreditationName = 'suspensionAccreditation'; + const issueResponse = await request.post(`/trust-registry/accreditation/issue?accreditationType=authorize`, { + data: JSON.stringify(payload), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const { didUrls, accreditation } = await issueResponse.json(); + expect(didUrls).toContain(didUrl); + expect(issueResponse.status()).toBe(StatusCodes.OK); + expect(accreditation.proof.type).toBe('JwtProof2020'); + expect(accreditation.proof).toHaveProperty('jwt'); + expect(typeof accreditation.issuer === 'string' ? accreditation.issuer : accreditation.issuer.id).toBe( + payload.issuerDid + ); + expect(accreditation.type).toContain('VerifiableCredential'); + expect(accreditation.credentialSubject.id).toBe(payload.subjectDid); + expect(accreditation.credentialStatus).toMatchObject({ + type: 'StatusList2021Entry', + statusPurpose: 'suspension', + }); + expect(accreditation.credentialStatus).toHaveProperty('statusListIndex'); + expect(accreditation.credentialStatus).toHaveProperty('id'); +}); + +test(" Verify a Accreditation's suspension status", async ({ request }) => { + const response = await request.post(`/trust-registry/accreditation/verify?verifyStatus=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const result = await response.json(); + expect(response).toBeOK(); + expect(response.status()).toBe(StatusCodes.OK); + expect(result.verified).toBe(true); + expect(result.suspended).toBe(false); +}); + +test(' Verify a credential status after suspension', async ({ request }) => { + const response = await request.post(`/trust-registry/accreditation/suspend?publish=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const result = await response.json(); + expect(response).toBeOK(); + expect(response.status()).toBe(StatusCodes.OK); + expect(result.suspended).toBe(true); + expect(result.published).toBe(true); + + const verificationResponse = await request.post(`/trust-registry/accreditation/verify?verifyStatus=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const verificationResult = await verificationResponse.json(); + expect(verificationResponse).toBeOK(); + expect(verificationResponse.status()).toBe(StatusCodes.OK); + expect(verificationResult.verified).toBe(true); + expect(verificationResult.suspended).toBe(true); +}); + +test(' Verify a accreditation status after reinstating', async ({ request }) => { + const response = await request.post(`/trust-registry/accreditation/reinstate?publish=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const result = await response.json(); + expect(response).toBeOK(); + expect(response.status()).toBe(StatusCodes.OK); + expect(result.unsuspended).toBe(true); + + const verificationResponse = await request.post(`/trust-registry/accreditation/verify?verifyStatus=true`, { + data: JSON.stringify({ + didUrl, + subjectDid, + }), + headers: { + 'Content-Type': CONTENT_TYPE.APPLICATION_JSON, + }, + }); + const verificationResult = await verificationResponse.json(); + expect(verificationResponse).toBeOK(); + expect(verificationResponse.status()).toBe(StatusCodes.OK); + expect(verificationResult.verified).toBe(true); + expect(verificationResult.suspended).toBe(false); +});