Repository navigation
fix: scope changelog lookups to parent record and add SDM ACL pre-check #604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
439423f
4a0e7db
ed973c5
7d2327c
5a50df2
2d2183b
e5153fc
705ab8a
22218b7
c093c19
2125084
b202d20
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"))) { | ||
| 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()); | ||
|
|
@@ -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"); | ||
|
|
@@ -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(); | ||
|
|
@@ -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); | ||
| } | ||
|
|
@@ -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(); | ||
|
|
@@ -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__", ""); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) {
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
@@ -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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
USER_NOT_AUTHORISED_ERRORsays "...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.Fix: Use .contains() at minimum, or catch auth failures from getChangeLog() too. Better long-term: use an error code/enum rather than string comparison.
There was a problem hiding this comment.
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.