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