Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ private SDMErrorMessages() {
"\nPlease contact your administrator for assistance with any necessary adjustments.";
public static final String MAX_COUNT_ERROR_MESSAGE = "Cannot upload more than %s attachments.";
public static final String FETCH_CHANGELOG_ERROR = "Could not fetch the changelog";
public static final String CHANGELOG_NOT_AUTHORISED_ERROR =
"You do not have the required permissions to view the changelog. Please contact your administrator for access.";
public static final String FAILED_TO_MOVE_ATTACHMENT = "Failed to move attachment";
public static final String INVALID_SECONDARY_PROPERTIES_FOR_MOVE_PREFIX =
"Invalid secondary properties detected: ";
Expand Down
43 changes: 43 additions & 0 deletions sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,49 @@ public CmisDocument getObjectIdForAttachmentID(
return cmisDocument;
}

public CmisDocument getObjectIdForAttachmentID(
CdsEntity attachmentEntity,
PersistenceService persistenceService,
String id,
String upIdKey,
String upId) {
logger.debug(
"Fetching objectId for attachment ID: {} upId: {} from entity: {}",
id,
upId,
attachmentEntity.getQualifiedName());
CqnSelect q =
Select.from(attachmentEntity)
.columns(
"objectId",
"folderId",
"fileName",
"mimeType",
"contentId",
"linkUrl",
"uploadStatus")
.where(doc -> doc.get("ID").eq(id).and(doc.get(upIdKey).eq(upId)));
Result result = persistenceService.run(q);
Optional<Row> res = result.first();
CmisDocument cmisDocument = new CmisDocument();
if (res.isPresent()) {
logger.debug("Attachment found for ID: {} within parent scope", id);
Row row = res.get();
cmisDocument.setObjectId(row.get("objectId").toString());
cmisDocument.setFileName(row.get("fileName").toString());
cmisDocument.setFolderId(row.get("folderId").toString());
cmisDocument.setMimeType(row.get("mimeType").toString());
cmisDocument.setContentId(
row.get("contentId") != null ? row.get("contentId").toString() : null);
cmisDocument.setUrl(row.get("linkUrl") != null ? row.get("linkUrl").toString() : null);
cmisDocument.setUploadStatus(
row.get("uploadStatus") != null ? row.get("uploadStatus").toString() : null);
} else {
logger.debug("No attachment found for ID: {} within parent scope (upId: {})", id, upId);
}
return cmisDocument;
}

public CmisDocument getAttachmentForObjectID(
PersistenceService persistenceService, String id, AttachmentCopyEventContext context) {
logger.debug("Fetching attachment for objectId: {}", id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,30 +79,71 @@ public void changelog(AttachmentLogContext context) throws IOException {

CqnAnalyzer cqnAnalyzer = CqnAnalyzer.create(cdsModel);

Optional<CdsEntity> attachmentEntity =
Optional<CdsEntity> attachmentDraftEntity =
cdsModel.findEntity(context.getTarget().getQualifiedName() + "_drafts");

Map<String, Object> targetKeys =
cqnAnalyzer.analyze((CqnSelect) context.get("cqn")).targetKeyValues();

// get the objectId against the Id
String id = targetKeys.get("ID").toString();
logger.debug("Fetching changelog for attachment ID: {}", id);

CmisDocument cmisDocument =
dbQuery.getObjectIdForAttachmentID(attachmentEntity.get(), persistenceService, id);
// Derive parent entity name and upIdKey to scope the DB lookup by parent record
String targetQualifiedName = context.getTarget().getQualifiedName();
int lastDot = targetQualifiedName.lastIndexOf('.');
String parentEntityName = lastDot > 0 ? targetQualifiedName.substring(0, lastDot) : null;
Optional<CdsEntity> parentEntityOpt =
parentEntityName != null ? cdsModel.findEntity(parentEntityName) : Optional.empty();
String upIdKey =
attachmentDraftEntity.isPresent()
? SDMUtils.getUpIdKey(attachmentDraftEntity.get())
: "up__ID";

// fetchUPIDFromCQN extracts the parent record key from the navigation path in the CQN
String upId =
parentEntityOpt.isPresent()
? SDMUtils.fetchUPIDFromCQN((CqnSelect) context.get("cqn"), parentEntityOpt.get())
: null;

if (upId == null || upId.isEmpty()) {
logger.warn("Cannot determine parent scope for changelog request on attachment ID: {}", id);
throw new ServiceException(SDMUtils.getErrorMessage("FILE_NOT_FOUND_ERROR"));
}

CmisDocument cmisDocument = lookupAttachmentScoped(attachmentDraftEntity, id, upIdKey, upId);

if (cmisDocument.getFileName() == null || cmisDocument.getFileName().isEmpty()) {
// open attachment is triggered on non-draft entity
logger.debug("Draft entity returned empty fileName, fetching from active entity");
attachmentEntity = cdsModel.findEntity(context.getTarget().getQualifiedName());
Optional<CdsEntity> attachmentActiveEntity =
cdsModel.findEntity(context.getTarget().getQualifiedName());
cmisDocument = lookupAttachmentScoped(attachmentActiveEntity, id, upIdKey, upId);
}

cmisDocument =
dbQuery.getObjectIdForAttachmentID(attachmentEntity.get(), persistenceService, id);
if (cmisDocument.getObjectId() == null || cmisDocument.getObjectId().isEmpty()) {
logger.warn("Attachment ID {} not found within authorized parent scope (upId: {})", id, upId);
throw new ServiceException(SDMUtils.getErrorMessage("FILE_NOT_FOUND_ERROR"));
}

SDMCredentials sdmCredentials = tokenHandler.getSDMCredentials();

try {
JSONObject objectResponse =
sdmService.getObject(
cmisDocument.getObjectId(), sdmCredentials, context.getUserInfo().isSystemUser());
if (objectResponse == null) {
logger.warn("File not found in SDM for objectId: {}", cmisDocument.getObjectId());
throw new ServiceException(SDMUtils.getErrorMessage("FILE_NOT_FOUND_ERROR"));
}
} catch (ServiceException e) {
if (e.getMessage() != null
&& e.getMessage().equals(SDMUtils.getErrorMessage("USER_NOT_AUTHORISED_ERROR"))) {

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.

  1. USER_NOT_AUTHORISED_ERROR says "...permissions to upload attachments" — semantically wrong for a read. If getObject() wraps the message or uses a different constant for a 403-on-read, equals() fails and the raw "upload attachments" message leaks to the user.
  2. If getChangeLog() itself returns 403, that error is not caught. The ACL guard is incomplete.

Fix: Use .contains() at minimum, or catch auth failures from getChangeLog() too. Better long-term: use an error code/enum rather than string comparison.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 - SDMServiceImpl.getObject() throws ServiceException(SDMUtils.getErrorMessage("USER_NOT_AUTHORISED_ERROR")) on HTTP 403 — that is the only exception it throws for access denial (line 383–384). The equals() check matches exactly what this method produces. Using contains() would actually be less safe since it could accidentally match unrelated error messages that happen to share a substring.

The "semantically wrong" concern doesn't apply here — USER_NOT_AUTHORISED_ERROR is the internal detection signal, not what the user sees. The user sees CHANGELOG_NOT_AUTHORISED_ERROR ("You do not have the required permissions to view the changelog...") which we throw after catching it.

2 - SDMServiceImpl.getChangeLog() also throws USER_NOT_AUTHORISED_ERROR on HTTP 403 (lines 946–947 and 969–970). However, this path is unreachable in practice: both getObject() and getChangeLog() use the same user token and the same objectId. If a user does not have SDM read access to the object, getObject() throws first and we never reach getChangeLog(). The pre-check is the correct and sufficient gate.

That said, happy to add a catch on getChangeLog() as a defensive safety net if the team prefers defence-in-depth here — it won't change runtime behaviour but makes the intent explicit.

logger.warn(
"User not authorized to view changelog for objectId: {}", cmisDocument.getObjectId());
throw new ServiceException(SDMUtils.getErrorMessage("CHANGELOG_NOT_AUTHORISED_ERROR"));
}
throw e;
}

JSONObject jsonObject =
sdmService.getChangeLog(
cmisDocument.getObjectId(), sdmCredentials, context.getUserInfo().isSystemUser());
Expand All @@ -114,6 +155,15 @@ public void changelog(AttachmentLogContext context) throws IOException {
logger.debug("END: Changelog event");
}

private CmisDocument lookupAttachmentScoped(
Optional<CdsEntity> entityOpt, String id, String upIdKey, String upId) {
if (entityOpt.isEmpty()) {
return new CmisDocument();
}
return dbQuery.getObjectIdForAttachmentID(
entityOpt.get(), persistenceService, id, upIdKey, upId);
}

@On(event = "copyAttachments")
public void copyAttachments(EventContext context) throws IOException {
logger.debug("START: Copy attachments event");
Expand Down Expand Up @@ -195,11 +245,12 @@ public void handleDraftDiscardForLinks(DraftCancelEventContext context) throws I
revertLinksForComposition(context, parentKeys, entry.getKey());
}
}
revertNestedEntityLinks(context);
revertNestedEntityLinks(context, parentKeys);
logger.debug("END: Handle draft discard for links");
}

private void revertNestedEntityLinks(DraftCancelEventContext context) throws IOException {
private void revertNestedEntityLinks(
DraftCancelEventContext context, Map<String, Object> parentKeys) throws IOException {
logger.debug("START: Revert nested entity links");

CdsEntity parentDraftEntity = context.getTarget();
Expand All @@ -214,7 +265,7 @@ private void revertNestedEntityLinks(DraftCancelEventContext context) throws IOE
.forEach(
composition -> {
try {
processNestedEntityComposition(context, composition);
processNestedEntityComposition(context, composition, parentKeys);
} catch (IOException e) {
throw new RuntimeException(e);
}
Expand All @@ -224,7 +275,8 @@ private void revertNestedEntityLinks(DraftCancelEventContext context) throws IOE
}

private void processNestedEntityComposition(
DraftCancelEventContext context, CdsElement composition) throws IOException {
DraftCancelEventContext context, CdsElement composition, Map<String, Object> parentKeys)
throws IOException {
logger.debug("Processing nested entity composition: {}", composition.getName());

CdsAssociationType associationType = (CdsAssociationType) composition.getType();
Expand All @@ -243,12 +295,26 @@ private void processNestedEntityComposition(
return;
}

// Get the actual key field names from the entity instead of hardcoding "ID"
String upIdKey = SDMUtils.getUpIdKey(nestedDraftEntity.get());
if (upIdKey == null || upIdKey.isEmpty()) {
logger.debug("No upIdKey found for nested entity: {}, skipping", targetEntityName);
return;
}
String parentKeyName = upIdKey.replaceFirst("^up__", "");

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.

If parentKeys doesn't contain parentKeyName (custom key names, composite keys, or the test value new HashMap<>()), parentId is null. CQN's .eq(null) translates to IS NULL and returns zero rows. The nested entity revert silently does nothing no error is thrown, nothing is logged at warn level. This is a regression for all multi-level compositions.

Fix:

if (parentId == null) {
logger.warn("parentId null for upIdKey {} in entity {}, skipping revert", upIdKey, targetEntityName);
return;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Object parentId = parentKeys.get(parentKeyName);
if (parentId == null) {
logger.warn(
"parentId is null for upIdKey '{}' in entity '{}', skipping revert",
upIdKey,
targetEntityName);
return;
}
List<String> keyElementNames = getKeyElementNames(nestedDraftEntity.get());

Result nestedRecords =
persistenceService.run(
Select.from(nestedDraftEntity.get()).where(e -> e.get("IsActiveEntity").eq(false)));
Select.from(nestedDraftEntity.get())
.where(e -> e.get(upIdKey).eq(parentId).and(e.get("IsActiveEntity").eq(false))));
logger.debug("Found {} nested records to process", nestedRecords.rowCount());

for (Row nestedRecord : nestedRecords) {
Expand Down Expand Up @@ -294,6 +360,13 @@ private void revertLinksForComposition(
}
String parentKeyName = upIdKey.replaceFirst("^up__", "");
Object parentId = parentKeys.get(parentKeyName);
if (parentId == null) {
logger.warn(
"parentId is null for upIdKey '{}', skipping link revert for: {}",
upIdKey,
attachmentCompositionDefinition);
return;
}

CqnSelect selectDraftLinks =
Select.from(draftEntity)
Expand Down
35 changes: 35 additions & 0 deletions sdm/src/test/java/integration/com/sap/cds/sdm/Api.java
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,41 @@ public Map<String, Object> fetchChangelog(
}
}

@Override
public String fetchChangelogRaw(
String appUrl, String entityName, String facetName, String entityID, String ID)
throws IOException {
String url =
"https://"
+ appUrl
+ "/odata/v4/"
+ serviceName
+ "/"
+ entityName
+ "(ID="
+ entityID
+ ",IsActiveEntity=false)/"
+ facetName
+ "(up__ID="
+ entityID
+ ",ID="
+ ID
+ ",IsActiveEntity=false)/"
+ serviceName
+ ".changelog";

RequestBody body = RequestBody.create("{}", MediaType.parse("application/json"));
Request request =
new Request.Builder().url(url).addHeader("Authorization", token).post(body).build();

try (Response response = executeWithRetry(request)) {
String responseBody = response.body() != null ? response.body().string() : "";
return response.code() + "|" + responseBody;
} catch (IOException e) {
throw new IOException("Error fetching changelog raw: " + e.getMessage(), e);
}
}

@Override
public String createAttachmentInActive(
String appUrl, String entityName, String facetName, String entityID) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ public Map<String, Object> fetchChangelog(
String appUrl, String entityName, String facetName, String entityID, String ID)
throws IOException;

public String fetchChangelogRaw(
String appUrl, String entityName, String facetName, String entityID, String ID)
throws IOException;

public String downloadSelectedAttachments(
String appUrl, String entityName, String facetName, String entityID, List<String> ids)
throws IOException;
Expand Down
31 changes: 31 additions & 0 deletions sdm/src/test/java/integration/com/sap/cds/sdm/ApiMT.java
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,37 @@ public Map<String, Object> fetchChangelog(
}
}

@Override
public String fetchChangelogRaw(
String appUrl, String entityName, String facetName, String entityID, String ID)
throws IOException {
String url =
"https://"
+ appUrl
+ "/api/admin/"
+ entityName
+ "(ID="
+ entityID
+ ",IsActiveEntity=false)/"
+ facetName
+ "(up__ID="
+ entityID
+ ",ID="
+ ID
+ ",IsActiveEntity=false)/AdminService.changelog";

RequestBody body = RequestBody.create("{}", MediaType.parse("application/json"));
Request request =
new Request.Builder().url(url).addHeader("Authorization", token).post(body).build();

try (Response response = executeWithRetry(request)) {
String responseBody = response.body() != null ? response.body().string() : "";
return response.code() + "|" + responseBody;
} catch (IOException e) {
throw new IOException("Error fetching changelog raw: " + e.getMessage(), e);
}
}

@Override
public String createAttachmentInActive(
String appUrl, String entityName, String facetName, String entityID) throws IOException {
Expand Down
Loading
Loading