Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Unreleased

### Added

- Send out `attachmentStatusChanged` websocket event when the malware scanner finishes scanning. This requires websockets and draft being enabled.

### Fixed

- Security audit events (`AttachmentSizeExceeded`, `AttachmentUploadRejected`, `AttachmentDownloadRejected`) now log the real client IP on reverse-proxy deployments (e.g. BTP Cloud Foundry) as well by setting `X-Forwarded-For` as an attribute on the audit log.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ Scan status codes:
- `Infected`: The attachment is infected.
- `Failed`: Scanning failed.

> [!Tip]
> When a service is [enabled for WebSockets](https://cap.cloud.sap/docs/plugins/#websocket) and the entity with attachments is draft-enabled, the plugin automatically emits an `attachmentStatusChanged` WebSocket event once malware scanning completes. This means the SAP Fiori Elements UI will automatically refresh once scanning is complete.

> [!Note]
> The malware scanner supports mTLS authentication which requires an annual renewal of the certificate. Previously, basic authentication was used which has now been deprecated.

Expand Down
144 changes: 101 additions & 43 deletions lib/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require("./csn-runtime-extension")
const LOG = cds.log("attachments")

cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel)
cds.on("compile.for.runtime", unfoldModel)

// Register the db handler ONCE (not per-service) to intercept attachment INSERT
// and handle it through the attachments service instead of native DB insert
Expand Down Expand Up @@ -64,61 +65,118 @@ cds.once("served", () => {
})

/**
* Unfold the model to add necessary facets for attachments
* Unfold the model to add necessary facets, events and annotations for attachments
* @param {*} csn - CSN model
*/
function unfoldModel(csn) {
const meta = (csn.meta ??= {})
if (!("sap.attachments.Attachments" in csn.definitions)) return
if (meta._enhanced_for_attachments) return
// const csnCopy = structuredClone(csn) // REVISIT: Why did we add this cloning?
const hasFacetForComp = (comp, facets) =>
facets.some(
(f) =>
f.Target === `${comp.name}/@UI.LineItem` ||
(f.Facets && hasFacetForComp(comp, f.Facets)),
)
cds.linked(csn).forall("Composition", (comp) => {
if (
comp._target &&
comp._target["@_is_media_data"] &&
comp.parent &&
comp.is2many
) {
let facets = comp.parent["@UI.Facets"]
if (!facets) return
if (comp["@attachments.disable_facet"] !== undefined) {
LOG.warn(
`@attachments.disable_facet is deprecated! Please annotate ${comp.name} with @UI.Hidden`,
)
}

const servicesWithAttachments = new Set()

if (meta.flavor === "inferred") {
const hasFacetForComp = (comp, facets) =>
facets.some(
(f) =>
f.Target === `${comp.name}/@UI.LineItem` ||
(f.Facets && hasFacetForComp(comp, f.Facets)),
)
cds.linked(csn).forall("Composition", (comp) => {
if (
!comp["@attachments.disable_facet"] &&
!hasFacetForComp(comp, facets)
comp._target &&
comp._target["@_is_media_data"] &&
comp.parent &&
comp.is2many
) {
LOG.debug(`Adding @UI.Facet to: ${comp.parent.name}`)
const attachmentsFacet = {
$Type: "UI.ReferenceFacet",
Target: `${comp.name}/@UI.LineItem`,
ID: `${comp.name}_attachments`,
Label: "{i18n>Attachments}",
// Track services that have attachment compositions
const srvName = comp._target._service?.name
if (srvName) {
servicesWithAttachments.add(srvName)
}
if (comp["@UI.Hidden"]) {
attachmentsFacet["@UI.Hidden"] = comp["@UI.Hidden"]

// Add @Common.SideEffects annotation to parent entity targeting attachment composition
// Only annotate service-level entities (they appear in $metadata)
// REVISIT: Once FE supports row level targeting for these, adjust that not the whole attachments table is refreshed
const parentDef = csn.definitions[comp.parent.name]
const qualifier = `attachmentStatusChanged_${comp.name}`
if (
parentDef &&
comp._target._service &&
!parentDef[`@Common.SideEffects#${qualifier}`]
) {
parentDef[`@Common.SideEffects#${qualifier}`] = {
SourceEvents: ["attachmentStatusChanged"],
TargetEntities: [{ "=": comp.name }],
}
}
facets.push(attachmentsFacet)
//Hide parent key so it cannot be selected from Columns on the UI
Object.keys(comp._target.elements)
.filter((e) => e.startsWith("up__"))
.forEach((ele) => {
comp._target.elements[ele]["@UI.Hidden"] = true
})
if (comp._target.elements["up_"]) {
comp._target.elements["up_"]["@UI.Hidden"] = true

let facets = comp.parent["@UI.Facets"]
if (!facets) return
if (comp["@attachments.disable_facet"] !== undefined) {
LOG.warn(
`@attachments.disable_facet is deprecated! Please annotate ${comp.name} with @UI.Hidden`,
)
}
if (
!comp["@attachments.disable_facet"] &&
!hasFacetForComp(comp, facets)
) {
LOG.debug(`Adding @UI.Facet to: ${comp.parent.name}`)
const attachmentsFacet = {
$Type: "UI.ReferenceFacet",
Target: `${comp.name}/@UI.LineItem`,
ID: `${comp.name}_attachments`,
Label: "{i18n>Attachments}",
}
if (comp["@UI.Hidden"]) {
attachmentsFacet["@UI.Hidden"] = comp["@UI.Hidden"]
}
facets.push(attachmentsFacet)
//Hide parent key so it cannot be selected from Columns on the UI
Object.keys(comp._target.elements)
.filter((e) => e.startsWith("up__"))
.forEach((ele) => {
comp._target.elements[ele]["@UI.Hidden"] = true
})
if (comp._target.elements["up_"]) {
comp._target.elements["up_"]["@UI.Hidden"] = true
}
}
}
})
} else {
// For non-inferred CSN (e.g. compile.for.runtime), find services with attachment compositions
for (const [name, def] of Object.entries(csn.definitions)) {
if (def["@_is_media_data"] && def.kind === "entity") {
const parts = name.split(".")
// Service name is the first part for service-scoped entities
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(0, i).join(".")
if (csn.definitions[candidate]?.kind === "service") {
servicesWithAttachments.add(candidate)
break
}
}
}
}
})
}
Comment thread
schiwekM marked this conversation as resolved.

// Add attachmentStatusChanged event to services that have attachment compositions
for (const srvName of servicesWithAttachments) {
const eventName = `${srvName}.attachmentStatusChanged`
if (!csn.definitions[eventName]) {
csn.definitions[eventName] = {
kind: "event",
"@ws": true,
"@ws.format": "pcp",
"@ws.pcp.sideEffect": true,
elements: {
sideEffectSource: { type: "cds.String" },
},
}
}
}

meta._enhanced_for_attachments = true
}

Expand Down
18 changes: 10 additions & 8 deletions srv/attachments/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,16 @@ class AttachmentsService extends cds.Service {
})

const MalwareScanner = await cds.connect.to("malwareScanner")
await Promise.all(
data.map(async (d) => {
await MalwareScanner.emit("ScanAttachmentsFile", {
target: attachments.name,
keys: { ID: d.ID },
})
}),
)
cds.spawn({}, async () => {
await Promise.all(
data.map(async (d) => {
await MalwareScanner.emit("ScanAttachmentsFile", {
target: attachments.name,
keys: { ID: d.ID },
})
}),
)
})

return res
}
Expand Down
28 changes: 28 additions & 0 deletions srv/malware-scanner/malwareScanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,34 @@ class MalwareScanner extends cds.ApplicationService {
UPDATE.entity(target).where(where).set(updateObject),
UPDATE.entity(target.drafts).where(where).set(updateObject),
])
const broadcastUpdate = cds.spawn({}, async () => {
const [active, draft] = await Promise.all([
SELECT.one.from(target).where(where),
SELECT.one.from(target.drafts).where(where),
])
Comment on lines +203 to +206
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: updateStatus is called with a complex CQL expression array for where (not a plain key object) when scanning completes, but the spawned SELECT.one.from(target).where(where) and SELECT.one.from(target.drafts).where(where) will receive that complex expression on the initial updateStatus("Scanning") call and then again on the final status call with a hash-qualified expression. The CQL where array including or and xpr nodes may not be supported by all CDS DB adapters in the same way for SELECT, potentially returning no result and silently skipping the event emission.

Consider querying by the original plain keys object (passed as a separate parameter, or stored before the compound expression is built) rather than the raw where argument which can be an arbitrary CQL expression.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

const parentEntity = target.elements.up_?._target
if (parentEntity?._service) {
const srv = await cds.connect.to(parentEntity._service.name)
const parentEntityName = parentEntity.name.split(".").pop()
const result = draft ?? active
if (result) {
const isActiveEntity = !draft
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: isActiveEntity is derived from whether a draft row exists, but this is unreliable

const isActiveEntity = !draft means: if a draft record exists, isActiveEntity=false; otherwise true. However, draft is the result of SELECT.one.from(target.drafts).where(where) — if the draft row simply doesn't exist in the DB at query time (e.g., was already activated), this will incorrectly emit IsActiveEntity=true even if the attachment actually belongs to an active entity reached via draft activation. The logic works for the happy path but conflates "no draft row" with "is active entity", which can produce a wrong sideEffectSource path and fail to trigger the UI side-effect on the correct entity.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

const parentKeys = Object.keys(result)
.filter((k) => k.startsWith("up__"))
.map((k) => `${k.slice(4)}=${result[k]}`)
.join(",")
srv.emit("attachmentStatusChanged", {
sideEffectSource: `/${parentEntityName}(${parentKeys},IsActiveEntity=${isActiveEntity})`,
})
}
}
})
broadcastUpdate.on("failed", (error) => {
LOG.debug(
`Emitting websocket event attachmentStatusChanged failed with: `,
error,
)
})
} else {
await UPDATE.entity(target).where(where).set(updateObject)
}
Expand Down
1 change: 1 addition & 0 deletions tests/incidents-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "@capire/incidents",
"version": "1.0.0",
"dependencies": {
"@cap-js-community/websocket": "^1.10.1",
"@cap-js/attachments": "file:../../.",
"@cap-js/audit-logging": "^1.2.0",
"@cap-js/hana": "2.6.0",
Expand Down
6 changes: 6 additions & 0 deletions tests/incidents-app/srv/services.cds
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ using from '../db/attachments';
/**
* Service used by support personell, i.e. the incidents' 'processors'.
*/
@ws
@odata
@Common: {
WebSocketBaseURL: 'ws/processor',
WebSocketChannel #sideEffects: 'sideeffects',
}
service ProcessorService {
@cds.redirection.target
entity Incidents as projection on my.Incidents actions {
Expand Down
14 changes: 14 additions & 0 deletions tests/integration/attachments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3688,6 +3688,20 @@ describe("Tests for copy() on AttachmentsService", () => {
})
})

describe("$metadata includes SideEffects for attachment compositions", () => {
it("ProcessorService $metadata contains attachmentStatusChanged SideEffects on parent entities", async () => {
const { data } = await GET(`odata/v4/processor/$metadata?$format=json`)
const jsonStr = JSON.stringify(data)

// SideEffects annotation with our qualifier must be present on parent entity
expect(jsonStr).toContain(
"@Common.SideEffects#attachmentStatusChanged_attachments",
)
expect(jsonStr).toContain('"SourceEvents":["attachmentStatusChanged"]')
expect(jsonStr).toContain('"TargetEntities":["attachments"]')
})
})

/**
* Uploads attachment in draft mode using CDS test utilities
* @param {Object} utils - RequestSend utility instance
Expand Down
Loading
Loading