From f1d9fed89c4f0fe2cf2b194994985b94ab4fe550 Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Fri, 11 Sep 2026 18:09:48 -0700 Subject: [PATCH] :sparkles: Record an audit trail of Information Sharing Agreement changes after first publish. Why? Agreements are drafted and revised freely before they are published, so the initial draft should not be recorded. Once an agreement is signed, every subsequent change matters and must be traceable. What? Auditing is off until an agreement is first signed. Signing turns it on and records a "Signed" entry; after that, edits record "Updated" and reverting to draft records "Reverted to draft". A read-only Audit tab on the admin agreement page lists the trail once auditing is enabled. NOTE: adds an audit_enabled flag to information_sharing_agreements (backfilled true for already-published agreements) plus an information_sharing_agreement_audits table. See https://yg-hpw.atlassian.net/browse/TK-90 --- api/src/controllers/index.ts | 1 + ...ion-sharing-agreement-audits-controller.ts | 43 ++++++++++ ...te-information-sharing-agreement-audits.ts | 41 +++++++++ api/src/models/index.ts | 4 + .../information-sharing-agreement-audit.ts | 83 +++++++++++++++++++ .../models/information-sharing-agreement.ts | 5 ++ api/src/policies/index.ts | 1 + ...rmation-sharing-agreement-audits-policy.ts | 53 ++++++++++++ api/src/router.ts | 4 + .../index-serializer.ts | 27 ++++++ .../index.ts | 1 + .../show-serializer.ts | 2 + .../revert-to-draft-service.ts | 16 +++- .../sign-service.ts | 15 +++- .../update-service.ts | 16 +++- ...haring-agreement-audits-controller.test.ts | 73 ++++++++++++++++ .../revert-to-draft-service.test.ts | 59 +++++++++++++ .../sign-service.test.ts | 48 +++++++++++ .../update-service.test.ts | 48 ++++++++++- ...nformation-sharing-agreement-audits-api.ts | 47 +++++++++++ .../api/information-sharing-agreements-api.ts | 2 + .../InformationSharingAgreementAuditCard.vue | 83 +++++++++++++++++++ .../InformationSharingAgreementAuditsPage.vue | 49 +++++++++++ .../InformationSharingAgreementPage.vue | 11 +++ web/src/routes.ts | 7 ++ ...se-information-sharing-agreement-audits.ts | 65 +++++++++++++++ 26 files changed, 800 insertions(+), 4 deletions(-) create mode 100644 api/src/controllers/information-sharing-agreement-audits-controller.ts create mode 100644 api/src/db/migrations/20260911120000_create-information-sharing-agreement-audits.ts create mode 100644 api/src/models/information-sharing-agreement-audit.ts create mode 100644 api/src/policies/information-sharing-agreement-audits-policy.ts create mode 100644 api/src/serializers/information-sharing-agreement-audits/index-serializer.ts create mode 100644 api/src/serializers/information-sharing-agreement-audits/index.ts create mode 100644 api/tests/controllers/information-sharing-agreement-audits-controller.test.ts create mode 100644 api/tests/services/information-sharing-agreements/sign-service.test.ts create mode 100644 web/src/api/information-sharing-agreement-audits-api.ts create mode 100644 web/src/components/information-sharing-agreements/InformationSharingAgreementAuditCard.vue create mode 100644 web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementAuditsPage.vue create mode 100644 web/src/use/use-information-sharing-agreement-audits.ts diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index 11775cb5..7f329138 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -12,6 +12,7 @@ export { ExternalOrganizationsController } from "./external-organizations-contro export { GroupsController } from "./groups-controller" export { InformationSharingAgreementAccessGrantsController } from "./information-sharing-agreement-access-grants-controller" export { InformationSharingAgreementArchiveItemsController } from "./information-sharing-agreement-archive-items-controller" +export { InformationSharingAgreementAuditsController } from "./information-sharing-agreement-audits-controller" export { InformationSharingAgreementsController } from "./information-sharing-agreements-controller" export { NotificationsController } from "./notifications-controller" export { RetentionsController } from "./retentions-controller" diff --git a/api/src/controllers/information-sharing-agreement-audits-controller.ts b/api/src/controllers/information-sharing-agreement-audits-controller.ts new file mode 100644 index 00000000..7937ade7 --- /dev/null +++ b/api/src/controllers/information-sharing-agreement-audits-controller.ts @@ -0,0 +1,43 @@ +import logger from "@/utils/logger" + +import { InformationSharingAgreementAudit } from "@/models" +import { InformationSharingAgreementAuditsPolicy } from "@/policies" +import { IndexSerializer } from "@/serializers/information-sharing-agreement-audits" +import BaseController from "@/controllers/base-controller" + +export class InformationSharingAgreementAuditsController extends BaseController { + async index() { + try { + const where = this.buildWhere({ + informationSharingAgreementId: this.params.informationSharingAgreementId, + }) + const scopes = this.buildFilterScopes() + const scopedItems = InformationSharingAgreementAuditsPolicy.applyScope( + scopes, + this.currentUser + ) + + const totalCount = await scopedItems.count({ where }) + const informationSharingAgreementAudits = await scopedItems.findAll({ + where, + limit: this.pagination.limit, + offset: this.pagination.offset, + include: ["user"], + order: [["createdAt", "DESC"]], + }) + + const serializedItems = IndexSerializer.perform(informationSharingAgreementAudits) + return this.response.json({ + informationSharingAgreementAudits: serializedItems, + totalCount, + }) + } catch (error) { + logger.error("Error fetching information sharing agreement audits" + error) + return this.response.status(400).json({ + message: `Error fetching information sharing agreement audits: ${error}`, + }) + } + } +} + +export default InformationSharingAgreementAuditsController diff --git a/api/src/db/migrations/20260911120000_create-information-sharing-agreement-audits.ts b/api/src/db/migrations/20260911120000_create-information-sharing-agreement-audits.ts new file mode 100644 index 00000000..fc43c75b --- /dev/null +++ b/api/src/db/migrations/20260911120000_create-information-sharing-agreement-audits.ts @@ -0,0 +1,41 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("information_sharing_agreement_audits", function (table) { + table.increments("id").notNullable().primary() + table.integer("user_id").nullable().references("id").inTable("users") + table + .integer("information_sharing_agreement_id") + .notNullable() + .references("id") + .inTable("information_sharing_agreements") + table.string("action", 200).notNullable() + table.string("description", 2000).nullable() + + table + .specificType("created_at", "DATETIME2(0)") + .notNullable() + .defaultTo(knex.raw("GETUTCDATE()")) + table + .specificType("updated_at", "DATETIME2(0)") + .notNullable() + .defaultTo(knex.raw("GETUTCDATE()")) + table.specificType("deleted_at", "DATETIME2(0)") + }) + + await knex.schema.alterTable("information_sharing_agreements", function (table) { + table.boolean("audit_enabled").notNullable().defaultTo(false) + }) + + await knex("information_sharing_agreements") + .whereNot({ status: "draft" }) + .update({ audit_enabled: true }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable("information_sharing_agreements", function (table) { + table.dropColumn("audit_enabled") + }) + + await knex.schema.dropTable("information_sharing_agreement_audits") +} diff --git a/api/src/models/index.ts b/api/src/models/index.ts index dd086cea..1888e4c2 100644 --- a/api/src/models/index.ts +++ b/api/src/models/index.ts @@ -14,6 +14,7 @@ import InformationSharingAgreement from "@/models/information-sharing-agreement" import InformationSharingAgreementAccessGrant from "@/models/information-sharing-agreement-access-grant" import InformationSharingAgreementAccessGrantSibling from "@/models/information-sharing-agreement-access-grant-sibling" import InformationSharingAgreementArchiveItem from "@/models/information-sharing-agreement-archive-item" +import InformationSharingAgreementAudit from "@/models/information-sharing-agreement-audit" import Notification from "@/models/notification" import Retention from "@/models/retention" import User from "@/models/user" @@ -33,6 +34,7 @@ db.addModels([ InformationSharingAgreementAccessGrant, InformationSharingAgreementAccessGrantSibling, InformationSharingAgreementArchiveItem, + InformationSharingAgreementAudit, Notification, Retention, User, @@ -51,6 +53,7 @@ Group.establishScopes() InformationSharingAgreement.establishScopes() InformationSharingAgreementAccessGrant.establishScopes() InformationSharingAgreementArchiveItem.establishScopes() +InformationSharingAgreementAudit.establishScopes() Notification.establishScopes() Retention.establishScopes() User.establishScopes() @@ -70,6 +73,7 @@ export { InformationSharingAgreementAccessGrant, InformationSharingAgreementAccessGrantSibling, InformationSharingAgreementArchiveItem, + InformationSharingAgreementAudit, Notification, Retention, User, diff --git a/api/src/models/information-sharing-agreement-audit.ts b/api/src/models/information-sharing-agreement-audit.ts new file mode 100644 index 00000000..8b7eaa2a --- /dev/null +++ b/api/src/models/information-sharing-agreement-audit.ts @@ -0,0 +1,83 @@ +import { + type CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + type NonAttribute, + sql, +} from "@sequelize/core" +import { + Attribute, + AutoIncrement, + BelongsTo, + Default, + NotNull, + PrimaryKey, +} from "@sequelize/core/decorators-legacy" + +import BaseModel from "@/models/base-model" +import InformationSharingAgreement from "@/models/information-sharing-agreement" +import User from "@/models/user" + +export class InformationSharingAgreementAudit extends BaseModel< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + declare id: CreationOptional + + @Attribute(DataTypes.INTEGER) + declare userId?: number + + @Attribute(DataTypes.INTEGER) + @NotNull + declare informationSharingAgreementId: number + + @Attribute(DataTypes.STRING(200)) + @NotNull + declare action: string + + @Attribute(DataTypes.STRING(2000)) + declare description?: string + + @Attribute(DataTypes.DATE(0)) + @NotNull + @Default(sql.fn("getutcdate")) + declare createdAt: CreationOptional + + @Attribute(DataTypes.DATE(0)) + @NotNull + @Default(sql.fn("getutcdate")) + declare updatedAt: CreationOptional + + @Attribute(DataTypes.DATE(0)) + declare deletedAt: Date | null + + // Associations + @BelongsTo(() => InformationSharingAgreement, { + foreignKey: "informationSharingAgreementId", + inverse: { + as: "informationSharingAgreementAudits", + type: "hasMany", + }, + }) + declare informationSharingAgreement?: NonAttribute + + @BelongsTo(() => User, { + foreignKey: "userId", + inverse: { + as: "informationSharingAgreementAudits", + type: "hasMany", + }, + }) + declare user?: NonAttribute + + // Scopes + static establishScopes(): void { + this.addSearchScope(["action"]) + } +} + +export default InformationSharingAgreementAudit diff --git a/api/src/models/information-sharing-agreement.ts b/api/src/models/information-sharing-agreement.ts index 609ab7b0..69d14658 100644 --- a/api/src/models/information-sharing-agreement.ts +++ b/api/src/models/information-sharing-agreement.ts @@ -92,6 +92,11 @@ export class InformationSharingAgreement extends BaseModel< @Default("draft") declare status: CreationOptional + @Attribute(DataTypes.BOOLEAN) + @NotNull + @Default(false) + declare auditEnabled: CreationOptional + @Attribute(DataTypes.STRING(100)) declare identifier: string | null diff --git a/api/src/policies/index.ts b/api/src/policies/index.ts index 6c27c36a..2a63ae19 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -8,6 +8,7 @@ export { ExternalOrganizationPolicy } from "./external-organization-policy" export { GroupPolicy } from "./group-policy" export { InformationSharingAgreementAccessGrantPolicy } from "./information-sharing-agreement-access-grant-policy" export { InformationSharingAgreementArchiveItemPolicy } from "./information-sharing-agreement-archive-item-policy" +export { InformationSharingAgreementAuditsPolicy } from "./information-sharing-agreement-audits-policy" export { InformationSharingAgreementPolicy } from "./information-sharing-agreement-policy" export { NotificationsPolicy } from "./notifications-policy" export { RetentionPolicy } from "./retention-policy" diff --git a/api/src/policies/information-sharing-agreement-audits-policy.ts b/api/src/policies/information-sharing-agreement-audits-policy.ts new file mode 100644 index 00000000..800ba0b0 --- /dev/null +++ b/api/src/policies/information-sharing-agreement-audits-policy.ts @@ -0,0 +1,53 @@ +import { Attributes, FindOptions } from "@sequelize/core" + +import { Path } from "@/utils/deep-pick" +import { InformationSharingAgreementAudit, User } from "@/models" +import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy" + +export class InformationSharingAgreementAuditsPolicy extends PolicyFactory( + InformationSharingAgreementAudit +) { + show(): boolean { + if (this.user.isSystemAdmin) return true + if (this.user.id === this.record.userId) return true + + return false + } + + create(): boolean { + return false + } + + update(): boolean { + return false + } + + destroy(): boolean { + return false + } + + permittedAttributes(): Path[] { + return [] + } + + permittedAttributesForCreate(): Path[] { + return [] + } + + static policyScope(user: User): FindOptions> { + if (user.isSystemAdmin) return ALL_RECORDS_SCOPE + + return { + include: [ + { + association: "informationSharingAgreement", + where: { + creatorId: user.id, + }, + }, + ], + } + } +} + +export default InformationSharingAgreementAuditsPolicy diff --git a/api/src/router.ts b/api/src/router.ts index 0916d873..0aec50c3 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -34,6 +34,7 @@ import { GroupsController, InformationSharingAgreementAccessGrantsController, InformationSharingAgreementArchiveItemsController, + InformationSharingAgreementAuditsController, InformationSharingAgreements, InformationSharingAgreementsController, Notifications, @@ -189,6 +190,9 @@ router router .route("/api/information-sharing-agreements/:informationSharingAgreementId/archive-items") .post(InformationSharingAgreements.ArchiveItemsController.create) +router + .route("/api/information-sharing-agreements/:informationSharingAgreementId/audits") + .get(InformationSharingAgreementAuditsController.index) router .route("/api/information-sharing-agreement-access-grants") diff --git a/api/src/serializers/information-sharing-agreement-audits/index-serializer.ts b/api/src/serializers/information-sharing-agreement-audits/index-serializer.ts new file mode 100644 index 00000000..4047dd77 --- /dev/null +++ b/api/src/serializers/information-sharing-agreement-audits/index-serializer.ts @@ -0,0 +1,27 @@ +import { pick } from "lodash" + +import { InformationSharingAgreementAudit } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" +import ReferenceSerializer, { UserAsReference } from "@/serializers/users/reference-serializer" + +export type InformationSharingAgreementAuditIndexView = Pick< + InformationSharingAgreementAudit, + "id" | "informationSharingAgreementId" | "action" | "description" | "createdAt" +> & { user: UserAsReference | null } + +export class IndexSerializer extends BaseSerializer { + perform(): InformationSharingAgreementAuditIndexView { + return { + ...pick(this.record, [ + "id", + "informationSharingAgreementId", + "action", + "description", + "createdAt", + ]), + user: this.record.user ? ReferenceSerializer.perform(this.record.user) : null, + } + } +} + +export default IndexSerializer diff --git a/api/src/serializers/information-sharing-agreement-audits/index.ts b/api/src/serializers/information-sharing-agreement-audits/index.ts new file mode 100644 index 00000000..9eec61a2 --- /dev/null +++ b/api/src/serializers/information-sharing-agreement-audits/index.ts @@ -0,0 +1 @@ +export { IndexSerializer } from "./index-serializer" diff --git a/api/src/serializers/information-sharing-agreements/show-serializer.ts b/api/src/serializers/information-sharing-agreements/show-serializer.ts index 8c840bd8..7f1a887d 100644 --- a/api/src/serializers/information-sharing-agreements/show-serializer.ts +++ b/api/src/serializers/information-sharing-agreements/show-serializer.ts @@ -16,6 +16,7 @@ export type InformationSharingAgreementAsShow = Pick< | "internalGroupContactId" | "internalGroupSecondaryContactId" | "status" + | "auditEnabled" | "identifier" | "externalGroupInfo" | "internalGroupInfo" @@ -91,6 +92,7 @@ export class ShowSerializer extends BaseSerializer "internalGroupContactId", "internalGroupSecondaryContactId", "status", + "auditEnabled", "identifier", "externalGroupInfo", "internalGroupInfo", diff --git a/api/src/services/information-sharing-agreements/revert-to-draft-service.ts b/api/src/services/information-sharing-agreements/revert-to-draft-service.ts index a1d6ff8b..7f6ba36e 100644 --- a/api/src/services/information-sharing-agreements/revert-to-draft-service.ts +++ b/api/src/services/information-sharing-agreements/revert-to-draft-service.ts @@ -1,4 +1,8 @@ -import db, { InformationSharingAgreement, User } from "@/models" +import db, { + InformationSharingAgreement, + InformationSharingAgreementAudit, + User, +} from "@/models" import BaseService from "@/services/base-service" import { InformationSharingAgreements } from "@/services" @@ -25,6 +29,16 @@ export class RevertToDraftService extends BaseService { signedById: null, signedAt: null, }) + + if (this.informationSharingAgreement.auditEnabled) { + await InformationSharingAgreementAudit.create({ + informationSharingAgreementId: this.informationSharingAgreement.id, + userId: this.currentUser.id, + action: "Reverted to draft", + description: `${this.currentUser.displayName} reverted the agreement to draft`, + }) + } + return this.informationSharingAgreement.reload({ include: [ "accessGrants", diff --git a/api/src/services/information-sharing-agreements/sign-service.ts b/api/src/services/information-sharing-agreements/sign-service.ts index f50a8313..673bca98 100644 --- a/api/src/services/information-sharing-agreements/sign-service.ts +++ b/api/src/services/information-sharing-agreements/sign-service.ts @@ -1,7 +1,12 @@ import { DateTime } from "luxon" import { isNil, truncate } from "lodash" -import db, { Attachment, InformationSharingAgreement, User } from "@/models" +import db, { + Attachment, + InformationSharingAgreement, + InformationSharingAgreementAudit, + User, +} from "@/models" import BaseService from "@/services/base-service" import { Attachments, InformationSharingAgreements } from "@/services" @@ -93,6 +98,14 @@ export class SignService extends BaseService { status: InformationSharingAgreement.Status.SIGNED, signedById: this.currentUser.id, signedAt: new Date(), + auditEnabled: true, + }) + + await InformationSharingAgreementAudit.create({ + informationSharingAgreementId: this.informationSharingAgreement.id, + userId: this.currentUser.id, + action: "Signed", + description: `${this.currentUser.displayName} signed the agreement`, }) await this.createGroups(this.informationSharingAgreement, this.currentUser) diff --git a/api/src/services/information-sharing-agreements/update-service.ts b/api/src/services/information-sharing-agreements/update-service.ts index f23b0b15..f42fa18f 100644 --- a/api/src/services/information-sharing-agreements/update-service.ts +++ b/api/src/services/information-sharing-agreements/update-service.ts @@ -1,7 +1,12 @@ import { Attributes } from "@sequelize/core" import { isNil } from "lodash" -import db, { InformationSharingAgreement, User, UserGroup } from "@/models" +import db, { + InformationSharingAgreement, + InformationSharingAgreementAudit, + User, + UserGroup, +} from "@/models" import BaseService from "@/services/base-service" import { UserGroups } from "@/services" @@ -28,6 +33,15 @@ export class UpdateService extends BaseService { return db.transaction(async () => { await this.informationSharingAgreement.update(this.attributes) + if (this.informationSharingAgreement.auditEnabled) { + await InformationSharingAgreementAudit.create({ + informationSharingAgreementId: this.informationSharingAgreement.id, + userId: this.currentUser.id, + action: "Updated", + description: `${this.currentUser.displayName} updated the agreement`, + }) + } + const { externalGroupId, internalGroupId, diff --git a/api/tests/controllers/information-sharing-agreement-audits-controller.test.ts b/api/tests/controllers/information-sharing-agreement-audits-controller.test.ts new file mode 100644 index 00000000..69415c2c --- /dev/null +++ b/api/tests/controllers/information-sharing-agreement-audits-controller.test.ts @@ -0,0 +1,73 @@ +import { InformationSharingAgreementAudit, User } from "@/models" + +import { informationSharingAgreementFactory, userFactory } from "@/tests/factories" + +import { mockCurrentUser, request } from "@/tests/support" + +describe("api/src/controllers/information-sharing-agreement-audits-controller.ts", () => { + describe("InformationSharingAgreementAuditsController", () => { + describe("#index", () => { + let currentUser: User + + beforeEach(async () => { + currentUser = await userFactory.create() + mockCurrentUser(currentUser) + }) + + test("returns the audit trail for an agreement the user owns", async () => { + const informationSharingAgreement = await informationSharingAgreementFactory.create({ + creatorId: currentUser.id, + }) + await InformationSharingAgreementAudit.create({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: currentUser.id, + action: "Signed", + description: `${currentUser.displayName} signed the agreement`, + }) + await InformationSharingAgreementAudit.create({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: currentUser.id, + action: "Updated", + description: `${currentUser.displayName} updated the agreement`, + }) + + const response = await request().get( + `/api/information-sharing-agreements/${informationSharingAgreement.id}/audits` + ) + + expect(response.status).toBe(200) + expect(response.body.totalCount).toBe(2) + expect(response.body.informationSharingAgreementAudits).toHaveLength(2) + const actions = response.body.informationSharingAgreementAudits.map( + (audit: { action: string }) => audit.action + ) + expect(actions).toEqual(expect.arrayContaining(["Signed", "Updated"])) + expect(response.body.informationSharingAgreementAudits[0]).toMatchObject({ + informationSharingAgreementId: informationSharingAgreement.id, + user: { displayName: currentUser.displayName }, + }) + }) + + test("does not expose audits for an agreement the user does not own", async () => { + const otherUser = await userFactory.create() + const informationSharingAgreement = await informationSharingAgreementFactory.create({ + creatorId: otherUser.id, + }) + await InformationSharingAgreementAudit.create({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: otherUser.id, + action: "Signed", + description: `${otherUser.displayName} signed the agreement`, + }) + + const response = await request().get( + `/api/information-sharing-agreements/${informationSharingAgreement.id}/audits` + ) + + expect(response.status).toBe(200) + expect(response.body.totalCount).toBe(0) + expect(response.body.informationSharingAgreementAudits).toHaveLength(0) + }) + }) + }) +}) diff --git a/api/tests/services/information-sharing-agreements/revert-to-draft-service.test.ts b/api/tests/services/information-sharing-agreements/revert-to-draft-service.test.ts index 7ad912b8..d756799e 100644 --- a/api/tests/services/information-sharing-agreements/revert-to-draft-service.test.ts +++ b/api/tests/services/information-sharing-agreements/revert-to-draft-service.test.ts @@ -3,6 +3,7 @@ import { Group, InformationSharingAgreement, InformationSharingAgreementArchiveItem, + InformationSharingAgreementAudit, } from "@/models" import { AttachmentTargetTypes } from "@/models/attachment" @@ -16,6 +17,7 @@ import { } from "@/tests/factories" import RevertToDraftService from "@/services/information-sharing-agreements/revert-to-draft-service" +import UpdateService from "@/services/information-sharing-agreements/update-service" // Group removal fans out notifications that are irrelevant to reverting; silence them. vi.mock("@/mailers/groups/notify-user-of-removal-mailer", () => { @@ -108,6 +110,63 @@ describe("api/src/services/information-sharing-agreements/revert-to-draft-servic RevertToDraftService.perform(informationSharingAgreement, currentUser) ).rejects.toThrow("Only signed agreements can be reverted to draft.") }) + + describe("audit trail", () => { + async function buildAuditableSignedAgreement() { + const currentUser = await userFactory.create() + const internalGroup = await groupFactory.create({ isExternal: false }) + const externalGroup = await groupFactory.create({ isExternal: true }) + const informationSharingAgreement = await informationSharingAgreementFactory.create({ + status: InformationSharingAgreement.Status.SIGNED, + internalGroupId: internalGroup.id, + externalGroupId: externalGroup.id, + signedById: currentUser.id, + signedAt: new Date(), + auditEnabled: true, + }) + return { currentUser, informationSharingAgreement } + } + + test("records a 'Reverted to draft' audit", async () => { + const { currentUser, informationSharingAgreement } = + await buildAuditableSignedAgreement() + + await RevertToDraftService.perform(informationSharingAgreement, currentUser) + + const audits = await InformationSharingAgreementAudit.findAll() + expect(audits).toEqual([ + expect.objectContaining({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: currentUser.id, + action: "Reverted to draft", + description: `${currentUser.displayName} reverted the agreement to draft`, + }), + ]) + }) + + test("records an 'Updated' audit when a reverted-to-draft agreement is updated", async () => { + const { currentUser, informationSharingAgreement } = + await buildAuditableSignedAgreement() + + const reverted = await RevertToDraftService.perform( + informationSharingAgreement, + currentUser + ) + await UpdateService.perform(reverted, { title: "Amended Title" }, currentUser) + + const updateAudits = await InformationSharingAgreementAudit.findAll({ + where: { action: "Updated" }, + }) + expect(updateAudits).toEqual([ + expect.objectContaining({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: currentUser.id, + action: "Updated", + description: `${currentUser.displayName} updated the agreement`, + }), + ]) + }) + }) }) }) }) diff --git a/api/tests/services/information-sharing-agreements/sign-service.test.ts b/api/tests/services/information-sharing-agreements/sign-service.test.ts new file mode 100644 index 00000000..d6025293 --- /dev/null +++ b/api/tests/services/information-sharing-agreements/sign-service.test.ts @@ -0,0 +1,48 @@ +import { InformationSharingAgreement, InformationSharingAgreementAudit } from "@/models" + +import { informationSharingAgreementFactory, userFactory } from "@/tests/factories" + +import SignService from "@/services/information-sharing-agreements/sign-service" + +// Signing upserts confidentiality documents and provisions groups; those collaborators are +// exercised by their own suites and are irrelevant to the audit-trail behavior under test. +vi.mock("@/services/attachments/upsert-service", () => { + const UpsertServiceMock = { perform: vi.fn() } + return { UpsertService: UpsertServiceMock, default: UpsertServiceMock } +}) +vi.mock("@/services/information-sharing-agreements/create-groups-service", () => { + const CreateGroupsServiceMock = { perform: vi.fn() } + return { CreateGroupsService: CreateGroupsServiceMock, default: CreateGroupsServiceMock } +}) + +describe("api/src/services/information-sharing-agreements/sign-service.ts", () => { + describe("SignService", () => { + describe("#perform", () => { + test("records a single 'Signed' audit and enables the audit trail", async () => { + const currentUser = await userFactory.create() + const informationSharingAgreement = await informationSharingAgreementFactory.create({ + status: InformationSharingAgreement.Status.DRAFT, + }) + + await SignService.perform( + informationSharingAgreement, + { signedConfidentialityAcknowledgement: { path: "/tmp/signed-acknowledgement.docx" } }, + currentUser + ) + + const audits = await InformationSharingAgreementAudit.findAll() + expect(audits).toEqual([ + expect.objectContaining({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: currentUser.id, + action: "Signed", + description: `${currentUser.displayName} signed the agreement`, + }), + ]) + + const reloaded = await informationSharingAgreement.reload() + expect(reloaded.auditEnabled).toBe(true) + }) + }) + }) +}) diff --git a/api/tests/services/information-sharing-agreements/update-service.test.ts b/api/tests/services/information-sharing-agreements/update-service.test.ts index ede826ad..60061a6a 100644 --- a/api/tests/services/information-sharing-agreements/update-service.test.ts +++ b/api/tests/services/information-sharing-agreements/update-service.test.ts @@ -1,4 +1,8 @@ -import { InformationSharingAgreement, UserGroup } from "@/models" +import { + InformationSharingAgreement, + InformationSharingAgreementAudit, + UserGroup, +} from "@/models" import { externalOrganizationFactory, @@ -473,6 +477,48 @@ describe("api/src/services/information-sharing-agreements/update-service.ts", () const userGroups = await UserGroup.findAll() expect(userGroups).toHaveLength(0) }) + + describe("audit trail", () => { + test("does not record an audit when the agreement has never been signed", async () => { + const currentUser = await userFactory.create() + const informationSharingAgreement = await informationSharingAgreementFactory.create({ + title: "Original Title", + }) + + await UpdateService.perform( + informationSharingAgreement, + { title: "Updated Title" }, + currentUser + ) + + expect(await InformationSharingAgreementAudit.count()).toBe(0) + }) + + test("records an 'Updated' audit when the agreement has been signed", async () => { + const currentUser = await userFactory.create() + const informationSharingAgreement = await informationSharingAgreementFactory.create({ + title: "Original Title", + status: InformationSharingAgreement.Status.SIGNED, + auditEnabled: true, + }) + + await UpdateService.perform( + informationSharingAgreement, + { title: "Updated Title" }, + currentUser + ) + + const audits = await InformationSharingAgreementAudit.findAll() + expect(audits).toEqual([ + expect.objectContaining({ + informationSharingAgreementId: informationSharingAgreement.id, + userId: currentUser.id, + action: "Updated", + description: `${currentUser.displayName} updated the agreement`, + }), + ]) + }) + }) }) }) }) diff --git a/web/src/api/information-sharing-agreement-audits-api.ts b/web/src/api/information-sharing-agreement-audits-api.ts new file mode 100644 index 00000000..271064c2 --- /dev/null +++ b/web/src/api/information-sharing-agreement-audits-api.ts @@ -0,0 +1,47 @@ +import http from "@/api/http-client" +import { type User } from "@/api/users-api" + +export type InformationSharingAgreementAudit = { + id: number + userId?: number + informationSharingAgreementId: number + action: string + description: string | null + createdAt: Date | null + updatedAt: Date | null + + user?: User | null +} + +export type InformationSharingAgreementAuditWhereOptions = { + name?: string +} + +export type InformationSharingAgreementAuditFiltersOptions = { + search?: string | string[] +} + +export const informationSharingAgreementAuditsApi = { + async list( + informationSharingAgreementId: number, + params: { + where?: InformationSharingAgreementAuditWhereOptions + filters?: InformationSharingAgreementAuditFiltersOptions + page?: number + perPage?: number + } = {} + ): Promise<{ + informationSharingAgreementAudits: InformationSharingAgreementAudit[] + totalCount: number + }> { + const { data } = await http.get( + `/api/information-sharing-agreements/${informationSharingAgreementId}/audits`, + { + params, + } + ) + return data + }, +} + +export default informationSharingAgreementAuditsApi diff --git a/web/src/api/information-sharing-agreements-api.ts b/web/src/api/information-sharing-agreements-api.ts index b9d45aa4..23a62152 100644 --- a/web/src/api/information-sharing-agreements-api.ts +++ b/web/src/api/information-sharing-agreements-api.ts @@ -77,6 +77,7 @@ export type InformationSharingAgreement = { disclosureNotes: string | null startDate: string | null endDate: string | null + auditEnabled: boolean createdAt: string updatedAt: string } @@ -142,6 +143,7 @@ export type InformationSharingAgreementAsShow = Pick< | "breachActions" | "breachNotes" | "disclosureNotes" + | "auditEnabled" | "createdAt" | "updatedAt" > & { diff --git a/web/src/components/information-sharing-agreements/InformationSharingAgreementAuditCard.vue b/web/src/components/information-sharing-agreements/InformationSharingAgreementAuditCard.vue new file mode 100644 index 00000000..2e63fc68 --- /dev/null +++ b/web/src/components/information-sharing-agreements/InformationSharingAgreementAuditCard.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementAuditsPage.vue b/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementAuditsPage.vue new file mode 100644 index 00000000..d94eba6d --- /dev/null +++ b/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementAuditsPage.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementPage.vue b/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementPage.vue index 1683139c..9095a06a 100644 --- a/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementPage.vue +++ b/web/src/pages/administration/information-sharing-agreements/InformationSharingAgreementPage.vue @@ -45,6 +45,17 @@ > Access Grants + + Audit + diff --git a/web/src/routes.ts b/web/src/routes.ts index 04f55761..77adbfe7 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -255,6 +255,13 @@ const routes: RouteRecordRaw[] = [ import("@/pages/administration/information-sharing-agreements/InformationSharingAgreementAccessGrantsPage.vue"), props: true, }, + { + path: "audits", + name: "administration/information-sharing-agreements/InformationSharingAgreementAuditsPage", + component: () => + import("@/pages/administration/information-sharing-agreements/InformationSharingAgreementAuditsPage.vue"), + props: true, + }, { path: "access-grants/new", name: "administration/information-sharing-agreements/InformationSharingAgreementAccessGrantNewPage", diff --git a/web/src/use/use-information-sharing-agreement-audits.ts b/web/src/use/use-information-sharing-agreement-audits.ts new file mode 100644 index 00000000..5482ab08 --- /dev/null +++ b/web/src/use/use-information-sharing-agreement-audits.ts @@ -0,0 +1,65 @@ +import informationSharingAgreementAuditsApi, { + InformationSharingAgreementAudit, + InformationSharingAgreementAuditFiltersOptions, + InformationSharingAgreementAuditWhereOptions, +} from "@/api/information-sharing-agreement-audits-api" +import { reactive, ref, Ref, toRefs, unref, watch } from "vue" + +export function useInformationSharingAgreementAudits( + informationSharingAgreementId: number, + queryOptions: Ref<{ + where?: InformationSharingAgreementAuditWhereOptions + filters?: InformationSharingAgreementAuditFiltersOptions + page?: number + perPage?: number + }> = ref({}), + { skipWatchIf = () => false }: { skipWatchIf?: () => boolean } = {} +) { + const state = reactive<{ + items: InformationSharingAgreementAudit[] + totalCount: number + isLoading: boolean + isErrored: boolean + }>({ + items: [], + totalCount: 0, + isLoading: false, + isErrored: false, + }) + + async function fetch(): Promise { + state.isLoading = true + try { + const { informationSharingAgreementAudits, totalCount } = + await informationSharingAgreementAuditsApi.list( + informationSharingAgreementId, + unref(queryOptions) + ) + state.isErrored = false + state.items = informationSharingAgreementAudits + state.totalCount = totalCount + return informationSharingAgreementAudits + } catch (error) { + console.error("Failed to fetch status:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + watch( + () => [skipWatchIf(), unref(queryOptions)], + async ([skip]) => { + if (skip) return + await fetch() + }, + { deep: true, immediate: true } + ) + + return { + ...toRefs(state), + fetch, + } +} + +export default useInformationSharingAgreementAudits