diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0bc817d4..32076bd2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
- If `@cap-js/audit-logging` is installed automatically trigger audit logs for the security events.
- Duplicate file names to a single attachment entity are automatically assigned a distinguishing suffix.
- Local testing using a Postgres database now possible.
+- Native server-side `copy()` method on `AttachmentsService` for copying attachments between entities without transferring binary data through the application. Supports all storage backends (DB, AWS S3, Azure Blob Storage, GCP Cloud Storage) with backend-native copy operations.
### Fixed
diff --git a/README.md b/README.md
index 8e53095d..1b2f8a89 100755
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ The `@cap-js/attachments` package is a [CDS plugin](https://cap.cloud.sap/docs/n
- [Visibility Control for Attachments UI Facet Generation](#visibility-control-for-attachments-ui-facet-generation)
- [Example Usage](#example-usage)
- [Non-Draft Upload](#non-draft-upload)
+ - [Copying Attachments](#copying-attachments)
- [Specify the maximum file size](#specify-the-maximum-file-size)
- [Restrict allowed MIME types](#restrict-allowed-mime-types)
- [Minimum and Maximum Number of Attachments](#minimum-and-maximum-number-of-attachments)
@@ -289,6 +290,79 @@ entity Incidents {
}
```
+### Copying Attachments
+
+The `AttachmentsService` exposes a programmatic `copy()` method that copies an attachment to a new record. On cloud storage backends (AWS S3, Azure Blob Storage, GCP Cloud Storage) this uses a backend-native server-side copy — no binary data is transferred through your application. On database storage it reads and inserts the content directly.
+
+**Signature:**
+
+```js
+const AttachmentsSrv = await cds.connect.to("attachments")
+await AttachmentsSrv.copy(
+ sourceAttachmentsEntity,
+ sourceKeys,
+ targetAttachmentsEntity,
+ (targetKeys = {}),
+)
+```
+
+| Parameter | Description |
+| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `sourceAttachmentsEntity` | CDS entity definition of the source attachment composition. |
+| `sourceKeys` | Keys of the attachment (e.g. `{ ID: '...' }`) |
+| `targetAttachmentsEntity` | CDS entity definition of the target attachment composition. |
+| `targetKeys` | Parent FK fields for the new record (e.g. `{ up__ID: '...' }`). When `targetAttachmentsEntity` is a draft table, must also include `DraftAdministrativeData_DraftUUID`. |
+
+The scan `status`, `lastScan`, and `hash` are inherited from the source — no re-scan is triggered since the binary content is identical. Copying an attachment when the status is not `Clean` is rejected with a `400` error.
+
+> [!NOTE]
+> Only copies within the same tenant are supported. Cross-tenant copies are not possible.
+
+#### Examples
+
+
+
+copy between two active records:
+
+```js
+const { Incidents } = ProcessorService.entities
+
+await AttachmentsSrv.copy(
+ Incidents.attachments,
+ { ID: sourceAttachmentID },
+ Incidents.attachments,
+ { up__ID: targetIncidentID },
+)
+```
+
+
+
+
+
+copy into a new draft record (e.g. creating an incident from a template)
+
+```js
+const { Incidents } = ProcessorService.entities
+
+// Look up the draft session UUID for the target incident's open draft
+const targetDraft = await SELECT.one
+ .from(Incidents.drafts, { ID: targetIncidentID })
+ .columns("DraftAdministrativeData_DraftUUID")
+
+await AttachmentsSrv.copy(
+ Incidents.attachments,
+ { ID: sourceAttachmentID },
+ Incidents.attachments.drafts,
+ {
+ up__ID: targetIncidentID,
+ DraftAdministrativeData_DraftUUID:
+ targetDraft.DraftAdministrativeData_DraftUUID,
+ },
+)
+```
+
+
+
### Non-Draft Upload
For scenarios where the entity is not draft-enabled, for example [`tests/non-draft-request.http`](./tests/non-draft-request.http), separate HTTP requests for metadata creation and asset uploading need to be performed manually.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 0833481e..bf8492b8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,6 +1,14 @@
import cds from "@sap/cds/eslint.config.mjs"
export default [
...cds,
+ {
+ rules: {
+ "no-unused-vars": [
+ "error",
+ { argsIgnorePattern: "^_", reportUsedIgnorePattern: true },
+ ],
+ },
+ },
{
name: "test-files-config",
files: ["tests/**/*"],
diff --git a/lib/generic-handlers.js b/lib/generic-handlers.js
index 9d312889..299bb333 100644
--- a/lib/generic-handlers.js
+++ b/lib/generic-handlers.js
@@ -9,9 +9,6 @@ const {
} = require("./helper")
const { getMime } = require("./mime")
-const isMultitenacyEnabled = !!cds.env.requires.multitenancy
-const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
-
/**
* Finalizes the preparation of a single attachment's data
* @param {object} data - The attachment data
@@ -57,10 +54,9 @@ async function finalizePrepareAttachment(data, req) {
}
if (!data.url) {
- data.url =
- isMultitenacyEnabled && objectStoreKind === "shared"
- ? `${req.tenant}_${cds.utils.uuid()}`
- : cds.utils.uuid()
+ const attachment = await cds.connect.to("attachments")
+ // Generate URL for object store
+ data.url = await attachment.createUrlForAttachment(data)
}
data.ID ??= cds.utils.uuid()
@@ -368,10 +364,9 @@ async function validateAndInsertAttachmentFromDBHandler(data, target, req) {
)
return
- // Generate URL for object store
- data.url = cds.utils.uuid()
-
const attachment = await cds.connect.to("attachments")
+ // Generate URL for object store
+ data.url = await attachment.createUrlForAttachment(data)
// Let the attachments service handle storage (object store + DB metadata)
await attachment.put(target, data)
diff --git a/srv/aws-s3.js b/srv/aws-s3.js
index 7afe1592..f0e02a13 100644
--- a/srv/aws-s3.js
+++ b/srv/aws-s3.js
@@ -3,6 +3,7 @@ const {
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
+ CopyObjectCommand,
} = require("@aws-sdk/client-s3")
const { Upload } = require("@aws-sdk/lib-storage")
const cds = require("@sap/cds")
@@ -328,6 +329,43 @@ module.exports = class AWSAttachmentsService extends require("./object-store") {
}
}
+ /**
+ * @inheritdoc
+ */
+ async copy(
+ sourceAttachmentsEntity,
+ sourceKeys,
+ targetAttachmentsEntity,
+ targetKeys = {},
+ ) {
+ LOG.debug("Copying attachment (S3)", {
+ source: sourceAttachmentsEntity.name,
+ sourceKeys,
+ target: targetAttachmentsEntity.name,
+ })
+ const safeTargetKeys = this._sanitizeTargetKeys(targetKeys)
+ const { source, newID, newUrl } = await this._prepareCopy(
+ sourceAttachmentsEntity,
+ sourceKeys,
+ )
+ const { client, bucket } = await this.retrieveClient()
+ if (await this.exists(newUrl)) {
+ const err = new Error("Target blob already exists")
+ err.status = 409
+ throw err
+ }
+ await client.send(
+ new CopyObjectCommand({
+ Bucket: bucket,
+ CopySource: `${bucket}/${source.url}`,
+ Key: newUrl,
+ }),
+ )
+ const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl }
+ await INSERT(newRecord).into(targetAttachmentsEntity)
+ return newRecord
+ }
+
/**
* Deletes a file from S3 based on the provided key
* @param {string} Key - The key of the file to delete
diff --git a/srv/azure-blob-storage.js b/srv/azure-blob-storage.js
index 040fd26f..c312bc6a 100644
--- a/srv/azure-blob-storage.js
+++ b/srv/azure-blob-storage.js
@@ -305,6 +305,39 @@ module.exports = class AzureAttachmentsService extends (
}
}
+ /**
+ * @inheritdoc
+ */
+ async copy(
+ sourceAttachmentsEntity,
+ sourceKeys,
+ targetAttachmentsEntity,
+ targetKeys = {},
+ ) {
+ LOG.debug("Copying attachment (Azure)", {
+ source: sourceAttachmentsEntity.name,
+ sourceKeys,
+ target: targetAttachmentsEntity.name,
+ })
+ const safeTargetKeys = this._sanitizeTargetKeys(targetKeys)
+ const { source, newID, newUrl } = await this._prepareCopy(
+ sourceAttachmentsEntity,
+ sourceKeys,
+ )
+ const { containerClient } = await this.retrieveClient()
+ if (await this.exists(newUrl)) {
+ const err = new Error("Target blob already exists")
+ err.status = 409
+ throw err
+ }
+ const sourceBlobClient = containerClient.getBlockBlobClient(source.url)
+ const targetBlobClient = containerClient.getBlockBlobClient(newUrl)
+ await targetBlobClient.syncCopyFromURL(sourceBlobClient.url)
+ const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl }
+ await INSERT(newRecord).into(targetAttachmentsEntity)
+ return newRecord
+ }
+
/**
* Deletes a file from Azure Blob Storage
* @param {string} Key - The key of the file to delete
diff --git a/srv/basic.js b/srv/basic.js
index 0370b7d4..83598c48 100644
--- a/srv/basic.js
+++ b/srv/basic.js
@@ -485,6 +485,132 @@ class AttachmentsService extends cds.Service {
}
}
+ /**
+ * Strips protected fields from targetKeys so callers cannot override
+ * security-sensitive metadata (status, hash, url, etc.).
+ * @param {object} targetKeys - Raw target keys from the caller
+ * @returns {object} - Sanitized target keys containing only FK fields
+ */
+ _sanitizeTargetKeys(targetKeys) {
+ const sanitized = {}
+ for (const [key, value] of Object.entries(targetKeys)) {
+ if (key.startsWith("up_") || key.startsWith("DraftAdministrativeData")) {
+ sanitized[key] = value
+ } else {
+ LOG.warn(`Ignoring protected field in targetKeys: ${key}`)
+ }
+ }
+ return sanitized
+ }
+
+ /**
+ *
+ * @param {*} data
+ * @returns
+ */
+ createUrlForAttachment() {
+ const isMultiTenancyEnabled = !!cds.env.requires.multitenancy
+ const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
+ return isMultiTenancyEnabled && objectStoreKind === "shared"
+ ? `${cds.context.tenant}_${cds.utils.uuid()}`
+ : cds.utils.uuid()
+ }
+
+ /**
+ * Prepares a copy operation by validating the source and generating new identifiers.
+ * Shared by all storage backends.
+ * @param {import('@sap/cds').Entity} sourceAttachmentsEntity - Source attachment entity definition
+ * @param {object} sourceKeys - Keys identifying the source attachment (e.g. { ID: '...' })
+ * @returns {Promise<{ source: object, newID: string, newUrl: string }>}
+ */
+ async _prepareCopy(sourceAttachmentsEntity, sourceKeys) {
+ // srv.run so auth is enforced
+ const srv = await cds.connect.to(
+ sourceAttachmentsEntity._service?.name ?? "db",
+ )
+ const source = await srv.run(
+ SELECT.one
+ .from(sourceAttachmentsEntity, sourceKeys)
+ .columns(
+ "url",
+ "filename",
+ "mimeType",
+ "note",
+ "hash",
+ "status",
+ "lastScan",
+ ),
+ )
+ if (!source) {
+ const err = new Error("Source attachment not found")
+ err.status = 404
+ throw err
+ }
+ if (source.status !== "Clean") {
+ const err = new Error(
+ `Cannot copy attachment with status: ${source.status}. Only a Clean Status is allowed`,
+ )
+ err.status = 400
+ throw err
+ }
+ const newUrl = this.createUrlForAttachment(source)
+
+ return { source, newID: cds.utils.uuid(), newUrl }
+ }
+
+ /**
+ * Copies an attachment to a new record, reusing the binary content from storage.
+ * For DB storage, reads content and inserts a new record.
+ * Cloud backends override this to use native server-side copy.
+ * Scan status, lastScan, and hash are inherited from the source — no re-scan needed.
+ *
+ * Tenant note: only copies within the same tenant are supported. Cross-tenant
+ * copies are not allowed because the storage backends resolve credentials for
+ * the current tenant only.
+ *
+ * @param {import('@sap/cds').Entity} sourceAttachmentsEntity - Source attachment entity definition.
+ * Pass `sourceAttachmentsEntity.drafts` to copy from a draft-only source.
+ * @param {object} sourceKeys - Keys identifying the source attachment (e.g. { ID: '...' })
+ * @param {import('@sap/cds').Entity} targetAttachmentsEntity - Target attachment entity definition.
+ * Pass `targetAttachmentsEntity.drafts` to insert into the draft shadow table (i.e. when the target
+ * parent entity is currently in a draft editing session). In that case targetKeys must include
+ * DraftAdministrativeData_DraftUUID.
+ * @param {object} [targetKeys={}] - Parent FK fields for the new record (e.g. { up__ID: '...' }).
+ * When targeting a draft table, must also include DraftAdministrativeData_DraftUUID.
+ * Protected fields (status, hash, url, etc.) are stripped automatically.
+ * @returns {Promise