diff --git a/apps/api/graphql/src/index.ts b/apps/api/graphql/src/index.ts index cd86b049bd..63f292d0df 100644 --- a/apps/api/graphql/src/index.ts +++ b/apps/api/graphql/src/index.ts @@ -135,7 +135,9 @@ export const handler = createHandler({ } }); }), - createAuditLogs(), + createAuditLogs({ + deleteLogsAfterDays: 30 + }), createCountDynamoDbTask(), createContinuingTask(), createHeadlessCmsScheduler({ diff --git a/jest.config.base.js b/jest.config.base.js index a0c0ed5df7..41c0c02312 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -94,6 +94,7 @@ module.exports = function ({ path }, presets = []) { process.env.DB_TABLE = "DynamoDB"; process.env.DB_TABLE_ELASTICSEARCH = "ElasticsearchStream"; process.env.DB_TABLE_LOG = "DynamoDBLog"; +process.env.DB_TABLE_AUDIT_LOGS = "DynamoDBAuditLogs"; process.env.WEBINY_VERSION = version; process.env.WEBINY_ELASTICSEARCH_INDEX_LOCALE = "true"; @@ -180,6 +181,27 @@ const createDynaliteTables = (options = {}) => { amount: 5 }), data: options.data || [] + }, + { + TableName: process.env.DB_TABLE_AUDIT_LOGS, + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, + { AttributeName: "SK", KeyType: "RANGE" } + ], + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" }, + ...createGlobalSecondaryIndexesAttributeDefinitions(2) + ], + ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 }, + GlobalSecondaryIndexes: createGlobalSecondaryIndexes({ + amount: 2 + }), + data: options.data || [], + ttl: { + attributeName: "expiresAt", + enabled: true + } } ], basePort: 8000 diff --git a/packages/api-aco/src/apps/AcoApp.ts b/packages/api-aco/src/apps/AcoApp.ts index 896c4f9ccd..a5392d79a5 100644 --- a/packages/api-aco/src/apps/AcoApp.ts +++ b/packages/api-aco/src/apps/AcoApp.ts @@ -35,11 +35,21 @@ export class AcoApp implements IAcoApp { private readonly onAnyRequest?: IAcoAppOnAnyRequest; public get search(): AcoSearchRecordCrudBase { + const getOne = async (id: string) => { + await this.execOnAnyRequest("fetch"); + const result = await this.context.aco.search.get(this.getModel(), id); + if (!result || !this.onEntry) { + return result; + } + return (await this.onEntry(result, this.context)) as SearchRecord; + }; + return { create: async ( data: CreateSearchRecordParams ) => { await this.execOnAnyRequest("create"); + const result = await this.context.aco.search.create(this.getModel(), data); if (!this.onEntry) { return result; @@ -51,6 +61,7 @@ export class AcoApp implements IAcoApp { data: UpdateSearchRecordParams ) => { await this.execOnAnyRequest("update"); + const result = await this.context.aco.search.update( this.getModel(), id, @@ -65,14 +76,7 @@ export class AcoApp implements IAcoApp { await this.execOnAnyRequest("move"); return this.context.aco.search.move(this.getModel(), id, folderId); }, - get: async (id: string) => { - await this.execOnAnyRequest("fetch"); - const result = await this.context.aco.search.get(this.getModel(), id); - if (!result || !this.onEntry) { - return result; - } - return (await this.onEntry(result, this.context)) as SearchRecord; - }, + get: getOne, list: async ( params: ListSearchRecordsParams ) => { @@ -88,6 +92,7 @@ export class AcoApp implements IAcoApp { }, delete: async (id: string): Promise => { await this.execOnAnyRequest("delete"); + return this.context.aco.search.delete(this.getModel(), id); }, listTags: async (params: ListSearchRecordTagsParams) => { diff --git a/packages/api-aco/src/apps/AcoApps.ts b/packages/api-aco/src/apps/AcoApps.ts index 14ca95844c..63ee76b1c5 100644 --- a/packages/api-aco/src/apps/AcoApps.ts +++ b/packages/api-aco/src/apps/AcoApps.ts @@ -15,10 +15,10 @@ export class AcoApps implements IAcoApps { this.options = options; } - public get(name: string): IAcoApp { + public get(name: string): IAcoApp { const app = this.apps.get(name); if (app) { - return app; + return app as IAcoApp; } throw new WebinyError(`App "${name}" is not registered.`, "APP_NOT_REGISTERED", { name, diff --git a/packages/api-aco/src/createAcoContext.ts b/packages/api-aco/src/createAcoContext.ts index c3a2e4fa89..696bcb9482 100644 --- a/packages/api-aco/src/createAcoContext.ts +++ b/packages/api-aco/src/createAcoContext.ts @@ -101,7 +101,7 @@ const setupAcoContext = async ( filter: createFilterCrudMethods(params), flp: flpCrudMethods, apps, - getApp: (name: string) => apps.get(name), + getApp: (name: string) => apps.get(name), listApps: () => apps.list(), registerApp: async (params: IAcoAppRegisterParams) => { return apps.register({ diff --git a/packages/api-aco/src/record/record.so.ts b/packages/api-aco/src/record/record.so.ts index 012e6db904..7783a78760 100644 --- a/packages/api-aco/src/record/record.so.ts +++ b/packages/api-aco/src/record/record.so.ts @@ -83,7 +83,7 @@ export const createSearchRecordOperations = ( const { createdBy, createdOn, modifiedBy, modifiedOn, savedBy, savedOn } = pickEntryMetaFields(data); - const entry = await cms.createEntry(model, { + const input = { tags, data, ...rest, @@ -100,7 +100,9 @@ export const createSearchRecordOperations = ( revisionSavedBy: savedBy, revisionSavedOn: savedOn, id: attachAcoRecordPrefix(rest.id) - }); + }; + + const entry = await cms.createEntry(model, input); return pickEntryFieldValues>(entry); }, diff --git a/packages/api-aco/src/types.ts b/packages/api-aco/src/types.ts index 79ee70f034..18a3ab26c6 100644 --- a/packages/api-aco/src/types.ts +++ b/packages/api-aco/src/types.ts @@ -64,7 +64,7 @@ export interface AdvancedContentOrganisation { folderLevelPermissions: FolderLevelPermissions; apps: IAcoApps; registerApp: (params: IAcoAppRegisterParams) => Promise; - getApp: (name: string) => IAcoApp; + getApp: (name: string) => IAcoApp; listApps: () => IAcoApp[]; } @@ -118,8 +118,8 @@ export interface IAcoAppModifyFieldCallable { (id: string, cb: IAcoAppModifyFieldCallableCallback): void; } -export interface IAcoApp { - context: AcoContext; +export interface IAcoApp { + context: C; search: AcoSearchRecordCrudBase; folder: AcoFolderCrud; name: string; diff --git a/packages/api-audit-logs/__tests__/createAuditLog.test.ts b/packages/api-audit-logs/__tests__/createAuditLog.test.ts index ee4d6cbbf2..d54b318d39 100644 --- a/packages/api-audit-logs/__tests__/createAuditLog.test.ts +++ b/packages/api-audit-logs/__tests__/createAuditLog.test.ts @@ -5,6 +5,16 @@ import { ActionType } from "~/config"; import { AUDIT_LOGS_TYPE } from "~/app/contants"; import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb/index"; import { parseIdentifier } from "@webiny/utils/parseIdentifier"; +import { attachAuditLogOnCreateEvent } from "~/app/lifecycle.js"; + +interface ITestPayloadData { + auditLogData: { + someData: boolean; + }; + moreNumberData: number; + evenMoreStringData: string; + additionalData?: string; +} describe("create audit log", () => { const client = getDocumentClient(); @@ -49,7 +59,7 @@ describe("create audit log", () => { const message = "Some Meaningful Message."; const entityId = "abcdefgh0001"; - const data = { + const data: ITestPayloadData = { auditLogData: { someData: true }, @@ -59,7 +69,7 @@ describe("create audit log", () => { const result = await createAuditLog(message, data, entityId, context); - expect(result).toEqual({ + expect(result).toMatchObject({ id: expect.any(String), title: message, content: message, @@ -68,7 +78,7 @@ describe("create audit log", () => { app: "cms", entity: "user", initiator: "id-12345678", - timestamp: expect.any(Date), + timestamp: expect.toBeDateString(), entityId, message, data: JSON.stringify(data) @@ -166,4 +176,76 @@ describe("create audit log", () => { }); } }); + + it("should trigger onBeforeCreate", async () => { + const createAuditLog = getAuditConfig(audit); + + const { handler } = useHandler({ + plugins: [ + attachAuditLogOnCreateEvent(async ({ payload, setPayload }) => { + setPayload({ + data: { + ...payload.data, + moreNumberData: 2, + additionalData: "something else" + } + }); + }) + ] + }); + const context = await handler(); + + const message = "Some Meaningful Message."; + const entityId = "abcdefgh0001"; + const data: ITestPayloadData = { + auditLogData: { + someData: true + }, + moreNumberData: 1, + evenMoreStringData: "abcdef" + }; + + const result = await createAuditLog(message, data, entityId, context); + + expect(result).toMatchObject({ + id: expect.any(String), + title: message, + content: message, + data: { + action: ActionType.CREATE, + app: "cms", + entity: "user", + initiator: "id-12345678", + timestamp: expect.toBeDateString(), + entityId, + message, + data: JSON.stringify({ + auditLogData: { + someData: true + }, + moreNumberData: 2, + evenMoreStringData: "abcdef", + additionalData: "something else" + }) + }, + location: { + folderId: "root" + }, + tags: [], + type: "AuditLogs" + }); + + const decompressedData = + // @ts-expect-error + JSON.parse(result.data.data); + + expect(decompressedData).toEqual({ + auditLogData: { + someData: true + }, + moreNumberData: 2, + evenMoreStringData: "abcdef", + additionalData: "something else" + }); + }); }); diff --git a/packages/api-audit-logs/package.json b/packages/api-audit-logs/package.json index 99c1747f65..e08b7eda22 100644 --- a/packages/api-audit-logs/package.json +++ b/packages/api-audit-logs/package.json @@ -40,6 +40,7 @@ "@webiny/api-mailer": "0.0.0", "@webiny/error": "0.0.0", "@webiny/handler": "0.0.0", + "@webiny/pubsub": "0.0.0", "@webiny/utils": "0.0.0", "@webiny/wcp": "0.0.0" } diff --git a/packages/api-audit-logs/src/app/app.ts b/packages/api-audit-logs/src/app/app.ts index 8ab528fc2d..4c1165dfdb 100644 --- a/packages/api-audit-logs/src/app/app.ts +++ b/packages/api-audit-logs/src/app/app.ts @@ -1,5 +1,4 @@ -import type { AcoContext } from "@webiny/api-aco/types"; -import type { IAcoAppRegisterParams, SearchRecord } from "@webiny/api-aco/types"; +import type { AcoContext, IAcoAppRegisterParams, SearchRecord } from "@webiny/api-aco/types"; import { AUDIT_LOGS_TYPE } from "./contants"; import { NotAuthorizedError } from "@webiny/api-security"; diff --git a/packages/api-audit-logs/src/app/createAppModifier.ts b/packages/api-audit-logs/src/app/createAppModifier.ts index 4aefadc814..b2845d36d9 100644 --- a/packages/api-audit-logs/src/app/createAppModifier.ts +++ b/packages/api-audit-logs/src/app/createAppModifier.ts @@ -1,10 +1,10 @@ import type { CreateAcoAppModifierCallable } from "@webiny/api-aco"; import { createAcoAppModifier as baseCreateAppModifier } from "@webiny/api-aco"; import { AUDIT_LOGS_TYPE } from "./contants"; -import type { AuditLogsAcoContext } from "./types"; import type { Context } from "@webiny/handler/types"; +import type { AuditLogsContext } from "~/types.js"; -export const createAppModifier = ( +export const createAppModifier = ( cb: CreateAcoAppModifierCallable ) => { return baseCreateAppModifier(AUDIT_LOGS_TYPE, cb); diff --git a/packages/api-audit-logs/src/app/index.ts b/packages/api-audit-logs/src/app/index.ts index 9935f99b4e..1b91ed0cb9 100644 --- a/packages/api-audit-logs/src/app/index.ts +++ b/packages/api-audit-logs/src/app/index.ts @@ -1,26 +1,48 @@ import { ContextPlugin } from "@webiny/api"; -import type { AuditLogsAcoContext } from "./types"; +import { createTopic } from "@webiny/pubsub"; +import type { + AuditLogsContext, + OnAuditLogBeforeCreateTopicParams, + OnAuditLogBeforeUpdateTopicParams +} from "~/types.js"; import { createApp } from "./app"; export * from "./createAppModifier"; -const setupContext = async (context: AuditLogsAcoContext): Promise => { +export interface ISetupContextOptions { + deleteLogsAfterDays: number; +} + +const setupContext = async ( + context: AuditLogsContext, + options?: ISetupContextOptions +): Promise => { + const onBeforeCreate = createTopic( + "auditLogs.onBeforeCreate" + ); + const onBeforeUpdate = createTopic( + "auditLogs.onBeforeUpdate" + ); + const app = await context.aco.registerApp(createApp()); context.auditLogsAco = { - app + app, + deleteLogsAfterDays: options?.deleteLogsAfterDays, + onBeforeCreate, + onBeforeUpdate }; }; -export const createAcoAuditLogsContext = () => { - const plugin = new ContextPlugin(async context => { +export const createAcoAuditLogsContext = (params?: ISetupContextOptions) => { + const plugin = new ContextPlugin(async context => { if (!context.aco) { console.log( `There is no ACO initialized so we will not initialize the Audit Logs ACO.` ); return; } - await setupContext(context); + await setupContext(context, params); }); plugin.name = "audit-logs-aco.createContext"; diff --git a/packages/api-audit-logs/src/app/lifecycle.ts b/packages/api-audit-logs/src/app/lifecycle.ts new file mode 100644 index 0000000000..93556227e4 --- /dev/null +++ b/packages/api-audit-logs/src/app/lifecycle.ts @@ -0,0 +1,40 @@ +import type { + AuditLogsContext, + OnAuditLogBeforeCreateTopicParams, + OnAuditLogBeforeUpdateTopicParams +} from "~/types.js"; +import { ContextPlugin } from "@webiny/api"; + +export const attachAuditLogOnCreateEvent = ( + cb: (params: OnAuditLogBeforeCreateTopicParams) => Promise +) => { + return new ContextPlugin(async context => { + if (!context.auditLogsAco) { + console.log( + `There is no Audit Logs ACO initialized so we will not attach the "onBeforeCreate" event.` + ); + return; + } + + context.auditLogsAco.onBeforeCreate.subscribe(async params => { + await cb(params); + }); + }); +}; + +export const attachAuditLogOnUpdateEvent = ( + cb: (params: OnAuditLogBeforeUpdateTopicParams) => Promise +) => { + return new ContextPlugin(async context => { + if (!context.auditLogsAco) { + console.log( + `There is no Audit Logs ACO initialized so we will not attach the "onBeforeUpdate" event.` + ); + return; + } + + context.auditLogsAco.onBeforeUpdate.subscribe(async params => { + await cb(params); + }); + }); +}; diff --git a/packages/api-audit-logs/src/app/types.ts b/packages/api-audit-logs/src/app/types.ts deleted file mode 100644 index fca9dda207..0000000000 --- a/packages/api-audit-logs/src/app/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AcoContext, IAcoApp } from "@webiny/api-aco/types"; -import type { Context as BaseContext } from "@webiny/handler/types"; - -export interface AuditLogsAcoContext extends BaseContext, AcoContext { - auditLogsAco: { - app: IAcoApp; - }; -} diff --git a/packages/api-audit-logs/src/index.ts b/packages/api-audit-logs/src/index.ts index 547cad9d2d..d152d944ac 100644 --- a/packages/api-audit-logs/src/index.ts +++ b/packages/api-audit-logs/src/index.ts @@ -3,7 +3,11 @@ import { createSubscriptionHooks } from "~/subscriptions"; import type { AuditLogsContext } from "~/types"; import { createAcoAuditLogsContext } from "~/app"; -export const createAuditLogs = () => { +export interface ICreateAuditLogsParams { + deleteLogsAfterDays: number; +} + +export const createAuditLogs = (params?: ICreateAuditLogsParams) => { const subscriptionsPlugin = new ContextPlugin(async context => { if (!context.wcp.canUseFeature("auditLogs")) { return; @@ -13,8 +17,8 @@ export const createAuditLogs = () => { subscriptionsPlugin.name = "auditLogs.context.subscriptions"; - return [subscriptionsPlugin, createAcoAuditLogsContext()]; + return [subscriptionsPlugin, createAcoAuditLogsContext(params)]; }; - export * from "~/config"; export * from "~/app/createAppModifier"; +export * from "~/app/lifecycle.js"; diff --git a/packages/api-audit-logs/src/types.ts b/packages/api-audit-logs/src/types.ts index 9f70ebbe81..2e47968278 100644 --- a/packages/api-audit-logs/src/types.ts +++ b/packages/api-audit-logs/src/types.ts @@ -1,10 +1,10 @@ -import type { AcoContext } from "@webiny/api-aco/types"; +import type { AcoContext, IAcoApp } from "@webiny/api-aco/types"; import type { MailerContext } from "@webiny/api-mailer/types"; import type { SecurityContext } from "@webiny/api-security/types"; import type { ApwContext } from "@webiny/api-apw/types"; import type { Context as BaseContext } from "@webiny/handler/types"; - -export * from "~/app/types"; +import type { GenericRecord } from "@webiny/api/types"; +import type { Topic } from "@webiny/pubsub/types.js"; export interface Action { type: string; @@ -36,17 +36,39 @@ export interface AuditLog { entity: string; entityId: string; action: string; - data: JSON; - timestamp: Date; + data: GenericRecord; + timestamp: string; initiator: string; } +export interface AuditLogPayload extends Omit { + data: T; +} + +export interface OnAuditLogBeforeCreateTopicParams { + payload: AuditLogPayload; + context: AcoContext; + setPayload(payload: Partial>): void; +} +export interface OnAuditLogBeforeUpdateTopicParams { + payload: AuditLogPayload; + original: AuditLogPayload; + context: AcoContext; + setPayload(payload: Partial>): void; +} export interface AuditLogsContext extends BaseContext, AcoContext, MailerContext, SecurityContext, - ApwContext {} + ApwContext { + auditLogsAco: { + app: IAcoApp; + deleteLogsAfterDays: number | undefined; + onBeforeCreate: Topic; + onBeforeUpdate: Topic; + }; +} export interface AuditObject { [app: string]: EntityObject; @@ -65,3 +87,21 @@ export interface AuditAction { entity: Entity; action: Action; } + +export type AuditLogType = "AuditLogs"; + +export interface AuditLogValuesData extends GenericRecord { + data: string; +} + +export interface AuditLogValues { + id: string; + title: string; + content: string; + tags: string[]; + type: AuditLogType; + location: { + folderId: string; + }; + data: AuditLogValuesData; +} diff --git a/packages/api-audit-logs/src/utils/getAuditConfig.ts b/packages/api-audit-logs/src/utils/getAuditConfig.ts index c981f8a215..9ea7681127 100644 --- a/packages/api-audit-logs/src/utils/getAuditConfig.ts +++ b/packages/api-audit-logs/src/utils/getAuditConfig.ts @@ -1,47 +1,62 @@ import WebinyError from "@webiny/error"; import { mdbid } from "@webiny/utils"; -import type { IAcoApp } from "@webiny/api-aco/types"; -import type { AuditAction, AuditLog, AuditLogsContext } from "~/types"; +import type { IAcoApp, SearchRecord } from "@webiny/api-aco/types"; +import type { AuditAction, AuditLogPayload, AuditLogsContext, AuditLogValues } from "~/types"; import type { GenericRecord } from "@webiny/api/types"; -interface AuditLogPayload extends Omit { - data: Record; +interface CreateAuditLogParams { + app: IAcoApp; + payload: AuditLogPayload; + deleteLogsAfterDays: number | undefined; } -interface CreateAuditLogParams { - app: IAcoApp; - payload: AuditLogPayload; -} - -const createAuditLog = async (params: CreateAuditLogParams) => { - const { app, payload } = params; +const createAuditLog = async (params: CreateAuditLogParams) => { + const { app, payload: input, deleteLogsAfterDays } = params; const compressor = app.context.compressor; - const payloadData = JSON.stringify(payload.data); + const expiresAtObj = createExpiresAt(deleteLogsAfterDays); + + const payload = structuredClone(input); try { - const entry = { + await app.context.auditLogsAco.onBeforeCreate.publish({ + /** + * We will assume that the payload is structured correctly. + */ + // @ts-expect-error + payload, + setPayload(values) { + Object.assign(payload, values); + }, + context: app.context + }); + const values: AuditLogValues = { id: mdbid(), title: payload.message, content: payload.message, tags: [], type: "AuditLogs", - location: { folderId: "root" }, + location: { + folderId: "root" + }, data: { ...payload, - data: payloadData - } + data: JSON.stringify(payload.data) + }, + ...expiresAtObj }; - const data = await compressor.compress(entry.data.data); - await app.search.create({ - ...entry, + + const data = await compressor.compress(values.data.data); + const entry = { + ...values, data: { - ...entry.data, + ...values.data, data: JSON.stringify(data) } - }); - return entry; + }; + await app.search.create(entry); + return values; } catch (error) { throw WebinyError.from(error, { message: "Error while creating new audit log", @@ -50,18 +65,51 @@ const createAuditLog = async (params: CreateAuditLogParams) => { } }; -interface CreateOrMergeAuditLogParams { - app: IAcoApp; - payload: AuditLogPayload; +interface CreateOrMergeAuditLogParams { + app: IAcoApp; + payload: AuditLogPayload; delay: number; + deleteLogsAfterDays: number | undefined; } -const createOrMergeAuditLog = async (params: CreateOrMergeAuditLogParams) => { - const { app, payload, delay } = params; +const createExpiresAt = (deleteLogsAfterDays: number | undefined) => { + if (!deleteLogsAfterDays || deleteLogsAfterDays <= 0) { + return {}; + } + return { + expireAt: Math.floor(Date.now() + (deleteLogsAfterDays * 24 * 60 * 60 * 1000) / 1000) + }; +}; + +interface IShouldCreateNewAuditLogParams { + original?: SearchRecord>; + payload: AuditLogPayload; + delay: number; +} + +const shouldCreateNewAuditLog = ( + params: IShouldCreateNewAuditLogParams +): boolean => { + const { original, payload, delay } = params; + if (!original) { + return true; + } + const existingLogDate = Date.parse(original.savedOn); + const newLogDate = new Date(payload.timestamp).getTime(); + if (newLogDate - existingLogDate < delay * 1000) { + return false; + } + return true; +}; + +const createOrMergeAuditLog = async (params: CreateOrMergeAuditLogParams) => { + const { app, payload, delay, deleteLogsAfterDays } = params; + + const expireAtObj = createExpiresAt(deleteLogsAfterDays); const compressor = app.context.compressor; // Get the latest audit log of this entry. - const [records] = await app.search.list({ + const [records] = await app.search.list>({ where: { type: "AuditLogs", data: { @@ -71,53 +119,62 @@ const createOrMergeAuditLog = async (params: CreateOrMergeAuditLogParams) => { }, limit: 1 }); - const existingLog = records?.[0]; - - if (existingLog) { - const existingLogDate = Date.parse(existingLog.savedOn); - const newLogDate = payload.timestamp.getTime(); - - // Check if the latest audit log is saved within delay range. - if (newLogDate - existingLogDate < delay * 1000) { - const existingLogData = (await compressor.decompress( - existingLog.data - )) as unknown as GenericRecord; - // Update latest audit log with new "after" payload. - const beforePayloadData = JSON.parse(existingLogData?.data.data)?.before; - const afterPayloadData = payload.data?.after; - const updatedPayloadData = beforePayloadData - ? JSON.stringify({ before: beforePayloadData, after: afterPayloadData }) - : JSON.stringify(payload.data); - - const data = await compressor.compress(updatedPayloadData); - try { - await app.search.update(existingLog.id, { - data: { - ...payload, - data - } - }); + const original = records[0] as SearchRecord>; - return { - ...existingLog, - data: updatedPayloadData - }; - } catch (error) { - throw WebinyError.from(error, { - message: "Error while updating audit log", - code: "UPDATE_AUDIT_LOG" - }); - } - } + if (shouldCreateNewAuditLog({ original, payload, delay })) { + return createAuditLog(params); } + // Update latest audit log with new "after" payload. + // @ts-expect-error + const beforePayloadData = JSON.parse(original?.data.data)?.before; + /** + * We can assume that there is a possible "after" in the payload data. + */ + // @ts-expect-error + const afterPayloadData = payload.data?.after; + const updatedPayloadData = beforePayloadData + ? JSON.stringify({ + before: beforePayloadData, + after: afterPayloadData + }) + : JSON.stringify(payload.data); + + await app.context.auditLogsAco.onBeforeUpdate.publish({ + payload: payload as AuditLogPayload, + original: original.data as AuditLogPayload, + context: app.context, + setPayload(input) { + Object.assign(payload, input); + } + }); + + const data = await compressor.compress(updatedPayloadData); + try { + await app.search.update(original.id, { + data: { + ...payload, + data: JSON.stringify(data) + }, + ...expireAtObj + }); - return createAuditLog(params); + return { + ...original, + data: updatedPayloadData, + ...expireAtObj + }; + } catch (error) { + throw WebinyError.from(error, { + message: "Error while updating audit log", + code: "UPDATE_AUDIT_LOG" + }); + } }; export const getAuditConfig = (audit: AuditAction) => { - return async ( + return async ( message: string, - data: Record, + data: T, entityId: string, context: AuditLogsContext ) => { @@ -130,37 +187,39 @@ export const getAuditConfig = (audit: AuditAction) => { const identity = security.getIdentity(); - const auditLogPayload = { + const auditLogPayload: AuditLogPayload = { message, app: audit.app.app, entity: audit.entity.type, entityId, action: audit.action.type, data, - timestamp: new Date(), + timestamp: new Date().toISOString(), initiator: identity?.id }; - const app = aco.getApp("AuditLogs"); - const delay = audit.action.newEntryDelay; + const app = aco.getApp("AuditLogs"); + const delay = audit.action.newEntryDelay || 0; // Check if there is delay on audit log creation for this action. - if (delay) { + if (delay > 0) { try { - return await createOrMergeAuditLog({ + return await createOrMergeAuditLog({ app, payload: auditLogPayload, - delay + delay, + deleteLogsAfterDays: context.auditLogsAco.deleteLogsAfterDays }); } catch { // Don't care at this point! } finally { - return JSON.stringify({}); + return null; } } - return await createAuditLog({ + return await createAuditLog({ app, - payload: auditLogPayload + payload: auditLogPayload, + deleteLogsAfterDays: context.auditLogsAco.deleteLogsAfterDays }); }; }; diff --git a/packages/api-audit-logs/tsconfig.build.json b/packages/api-audit-logs/tsconfig.build.json index 1ab96a8146..880f0e4d8b 100644 --- a/packages/api-audit-logs/tsconfig.build.json +++ b/packages/api-audit-logs/tsconfig.build.json @@ -8,6 +8,7 @@ { "path": "../api-mailer/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, + { "path": "../pubsub/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, { "path": "../wcp/tsconfig.build.json" }, { "path": "../api-admin-users/tsconfig.build.json" }, diff --git a/packages/api-audit-logs/tsconfig.json b/packages/api-audit-logs/tsconfig.json index ca55d1b658..7c574c2a97 100644 --- a/packages/api-audit-logs/tsconfig.json +++ b/packages/api-audit-logs/tsconfig.json @@ -8,6 +8,7 @@ { "path": "../api-mailer" }, { "path": "../error" }, { "path": "../handler" }, + { "path": "../pubsub" }, { "path": "../utils" }, { "path": "../wcp" }, { "path": "../api-admin-users" }, @@ -40,6 +41,8 @@ "@webiny/error": ["../error/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], "@webiny/utils/*": ["../utils/src/*"], "@webiny/utils": ["../utils/src"], "@webiny/wcp/*": ["../wcp/src/*"], diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 6d51355ef7..456734573f 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -462,6 +462,9 @@ export interface CmsEntryValues { [key: string]: any; } +export interface ICmsEntryLocation { + folderId?: string | null; +} /** * A content entry definition for and from the database. * @@ -653,9 +656,7 @@ export interface CmsEntry { /** * Advanced Content Organization */ - location?: { - folderId?: string | null; - }; + location?: ICmsEntryLocation; /** * Settings for the given entry. * diff --git a/packages/api-serverless-cms/__tests__/handlers/graphQlHandler.ts b/packages/api-serverless-cms/__tests__/handlers/graphQlHandler.ts index 856b217877..08007e10a4 100644 --- a/packages/api-serverless-cms/__tests__/handlers/graphQlHandler.ts +++ b/packages/api-serverless-cms/__tests__/handlers/graphQlHandler.ts @@ -12,7 +12,7 @@ import { createMutationFactory } from "~tests/handlers/helpers/factory/mutation" export interface IGraphQlHandlerParams { path: PathType; plugins?: Plugin[]; - features?: boolean | string[]; + features?: boolean; } export const useGraphQlHandler = (params: IGraphQlHandlerParams) => { diff --git a/packages/cwp-template-aws/template/common/types/env/index.d.ts b/packages/cwp-template-aws/template/common/types/env/index.d.ts index bc6484882b..563040c19b 100644 --- a/packages/cwp-template-aws/template/common/types/env/index.d.ts +++ b/packages/cwp-template-aws/template/common/types/env/index.d.ts @@ -2,14 +2,9 @@ declare namespace NodeJS { export interface ProcessEnv { NODE_ENV?: "test" | "prod" | "dev" | string; DB_TABLE?: string; - DB_TABLE_TENANCY?: string; - DB_TABLE_PRERENDERING_SERVICE?: string; DB_TABLE_ELASTICSEARCH?: string; - DB_TABLE_ADMIN_USERS?: string; - DB_TABLE_FILE_MANGER?: string; - DB_TABLE_HEADLESS_CMS?: string; - DB_PAGE_BUILDER?: string; - DB_TABLE_PAGE_BUILDER?: string; + DB_TABLE_LOG?: string; + DB_TABLE_AUDIT_LOGS?: string; ELASTICSEARCH_SHARED_INDEXES?: "true" | "false" | string; WEBINY_VERSION?: string; WEBINY_ENABLE_VERSION_HEADER?: "true" | "false" | string; diff --git a/packages/pulumi-aws/src/apps/api/AuditLogsDynamo.ts b/packages/pulumi-aws/src/apps/api/AuditLogsDynamo.ts new file mode 100644 index 0000000000..e135c966d3 --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/AuditLogsDynamo.ts @@ -0,0 +1,72 @@ +import * as aws from "@pulumi/aws"; +import type { PulumiApp, PulumiAppModule } from "@webiny/pulumi"; +import { createAppModule } from "@webiny/pulumi"; + +export type AuditLogsDynamo = PulumiAppModule; + +export const AuditLogsDynamo = createAppModule({ + name: "AuditLogsDynamoDb", + config(app: PulumiApp, params: { protect: boolean }) { + return app.addResource(aws.dynamodb.Table, { + name: "webiny-audit-logs", + config: { + attributes: [ + { name: "PK", type: "S" }, + { name: "SK", type: "S" }, + { name: "GSI1_PK", type: "S" }, + { name: "GSI1_SK", type: "S" }, + { name: "GSI2_PK", type: "S" }, + { name: "GSI2_SK", type: "S" }, + { name: "GSI3_PK", type: "S" }, + { name: "GSI3_SK", type: "S" }, + { name: "GSI4_PK", type: "S" }, + { name: "GSI4_SK", type: "S" }, + { name: "GSI5_PK", type: "S" }, + { name: "GSI5_SK", type: "S" } + ], + billingMode: "PAY_PER_REQUEST", + hashKey: "PK", + rangeKey: "SK", + globalSecondaryIndexes: [ + { + name: "GSI1", + hashKey: "GSI1_PK", + rangeKey: "GSI1_SK", + projectionType: "ALL" + }, + { + name: "GSI2", + hashKey: "GSI2_PK", + rangeKey: "GSI2_SK", + projectionType: "ALL" + }, + { + name: "GSI3", + hashKey: "GSI3_PK", + rangeKey: "GSI3_SK", + projectionType: "ALL" + }, + { + name: "GSI4", + hashKey: "GSI4_PK", + rangeKey: "GSI4_SK", + projectionType: "ALL" + }, + { + name: "GSI5", + hashKey: "GSI5_PK", + rangeKey: "GSI5_SK", + projectionType: "ALL" + } + ], + ttl: { + attributeName: "expiresAt", + enabled: true + } + }, + opts: { + protect: params.protect + } + }); + } +}); diff --git a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts index 5ab2cac236..2962125449 100644 --- a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts +++ b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts @@ -31,6 +31,7 @@ import { attachSyncSystem } from "../syncSystem/api/index.js"; import { getAwsAccountId } from "~/apps/awsUtils"; import type { WithServiceManifest } from "~/utils/withServiceManifest.js"; import { ApiScheduler } from "~/apps/api/ApiScheduler.js"; +import { AuditLogsDynamo } from "~/apps/api/AuditLogsDynamo.js"; export type ApiPulumiApp = ReturnType; @@ -47,6 +48,12 @@ export interface ApiOpenSearchConfig { } export interface CreateApiPulumiAppParams { + /** + * Secures against deleting database by accident. + * By default enabled in production environments. + */ + protect?: PulumiAppParam; + /** * Enables ElasticSearch infrastructure. * Note that it requires also changes in application code. @@ -151,6 +158,8 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = const vpcEnabled = app.getParam(projectAppParams?.vpc) ?? isProduction; app.addModule(VpcConfig, { enabled: vpcEnabled }); + const protect = app.getParam(projectAppParams.protect) ?? isProduction; + // const pageBuilder = app.addModule(ApiPageBuilder, { // env: { // COGNITO_REGION: getEnvVariableAwsRegion(), @@ -169,6 +178,10 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = // } // }); + const auditLogsDynamo = app.addModule(AuditLogsDynamo, { + protect + }); + const apwScheduler = app.addModule(ApiApwScheduler, { primaryDynamodbTableArn: core.primaryDynamodbTableArn, @@ -177,6 +190,7 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = COGNITO_USER_POOL_ID: core.cognitoUserPoolId, DB_TABLE: core.primaryDynamodbTableName, DB_TABLE_LOG: core.logDynamodbTableName, + DB_TABLE_AUDIT_LOGS: auditLogsDynamo.output.name, S3_BUCKET: core.fileManagerBucketId } }); @@ -187,6 +201,7 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = COGNITO_USER_POOL_ID: core.cognitoUserPoolId, DB_TABLE: core.primaryDynamodbTableName, DB_TABLE_LOG: core.logDynamodbTableName, + DB_TABLE_AUDIT_LOGS: auditLogsDynamo.output.name, DB_TABLE_ELASTICSEARCH: core.elasticsearchDynamodbTableName, ELASTIC_SEARCH_ENDPOINT: core.elasticsearchDomainEndpoint, @@ -286,6 +301,7 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = apwSchedulerEventRule: apwScheduler.eventRule.output.name, apwSchedulerEventTargetId: apwScheduler.eventTarget.output.targetId, dynamoDbTable: core.primaryDynamodbTableName, + auditLogsDynamoDbTable: auditLogsDynamo.output.name, migrationLambdaArn: migration.function.output.arn, graphqlLambdaName: graphql.functions.graphql.output.name, graphqlLambdaRole: graphql.role.output.arn, diff --git a/typings/env/index.d.ts b/typings/env/index.d.ts index e38e914a5c..979b5a053f 100644 --- a/typings/env/index.d.ts +++ b/typings/env/index.d.ts @@ -2,15 +2,9 @@ declare namespace NodeJS { export interface ProcessEnv { NODE_ENV?: "test" | "prod" | "dev" | string; DB_TABLE?: string; - DB_TABLE_TENANCY?: string; - DB_TABLE_PRERENDERING_SERVICE?: string; DB_TABLE_ELASTICSEARCH?: string; - DB_TABLE_ADMIN_USERS?: string; - DB_TABLE_FILE_MANGER?: string; - DB_TABLE_HEADLESS_CMS?: string; - DB_PAGE_BUILDER?: string; - DB_TABLE_PAGE_BUILDER?: string; DB_TABLE_LOG?: string; + DB_TABLE_AUDIT_LOGS?: string; ELASTICSEARCH_SHARED_INDEXES?: "true" | "false" | string; WEBINY_VERSION?: string; WEBINY_IS_PRE_529?: "true" | "false"; diff --git a/yarn.lock b/yarn.lock index bcddb9d4bb..f6cef77cad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15705,6 +15705,7 @@ __metadata: "@webiny/handler-graphql": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" + "@webiny/pubsub": "npm:0.0.0" "@webiny/utils": "npm:0.0.0" "@webiny/wcp": "npm:0.0.0" graphql: "npm:^15.9.0"