SED-4417 grid-layout-for-executions-report#603
SED-4417 grid-layout-for-executions-report#603david-stephan wants to merge 10 commits intomasterfrom
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request delivers a new capability for users to manage and share custom report layouts. It establishes the foundational components for this feature, from data persistence and retrieval to API endpoints and security mechanisms. The changes ensure that report layouts can be created, modified, and accessed with appropriate permissions, enhancing the flexibility and collaborative potential of reporting functionalities. Additionally, the system now tracks user IDs for entity modifications, providing more granular audit trails. Highlights
Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new ReportLayout entity to manage layouts, complete with access controls for shared and private settings. However, critical security vulnerabilities have been identified. Specifically, the ReportLayoutServices is vulnerable to IDOR due to missing ownership checks on resource retrieval endpoints, a flaw in the base AbstractEntityServices allows for ownership takeover of entities during updates, and the table registration for report layouts lacks proper filtering, leading to unauthorized information exposure of layout metadata. Additionally, a critical bug prevents the creation of new layouts, and a minor encapsulation issue was found. Addressing these points is crucial to ensure the privacy and integrity of user-defined layouts and the overall stability of the feature.
| @Tag(name = "ReportLayout") | ||
| @Tag(name = "Entity=ReportLayout") | ||
| @SecuredContext(key = "entity", value = ReportLayoutServices.REPORT_LAYOUT_RIGHT) | ||
| public class ReportLayoutServices extends AbstractEntityServices<ReportLayout> { |
There was a problem hiding this comment.
The ReportLayoutServices class inherits several data retrieval methods from AbstractEntityServices (such as get, findByIds, findNamesByIds, and findManyByAttributes) but does not override them to implement ownership or sharing checks. While the getAllReportLayouts method correctly filters the list of layouts, an attacker with the global reportLayout-read permission can still access any private layout by its ID or by searching for its attributes. This constitutes an Insecure Direct Object Reference (IDOR) vulnerability.
There was a problem hiding this comment.
All critical servies are now overriden
| if (sourceEntity == null) { | ||
| newTrackedEntity.setCreationDate(lastModificationDate); | ||
| newTrackedEntity.setCreationUser(username); | ||
| newTrackedEntity.setCreationUserId(userId); | ||
| } |
There was a problem hiding this comment.
In the trackEntityIfApplicable method, the creationUser and creationUserId fields are only set when a new entity is created (sourceEntity == null). During an update, these fields are not overwritten with the values from the existing database record. Since the entity object is deserialized from the request body, an attacker can provide their own creationUserId in the JSON payload to take over ownership of an existing entity. This allows for privilege escalation and unauthorized modification of resources.
if (sourceEntity == null) {
newTrackedEntity.setCreationDate(lastModificationDate);
newTrackedEntity.setCreationUser(username);
newTrackedEntity.setCreationUserId(userId);
} else {
AbstractTrackedObject sourceTrackedEntity = (AbstractTrackedObject) sourceEntity;
newTrackedEntity.setCreationDate(sourceTrackedEntity.getCreationDate());
newTrackedEntity.setCreationUser(sourceTrackedEntity.getCreationUser());
newTrackedEntity.setCreationUserId(sourceTrackedEntity.getCreationUserId());
}There was a problem hiding this comment.
Good point, this is now fixed.
| public ReportLayout save(ReportLayout reportLayout) { | ||
| //Only check additional specific rights when updating layout | ||
| Optional.ofNullable(get(reportLayout.getId().toHexString())).ifPresent(entity -> checkLayoutRight(entity, WRITE_RIGHT)); | ||
| return super.save(reportLayout); | ||
| } |
There was a problem hiding this comment.
The save method can throw a NullPointerException. For a new ReportLayout entity, reportLayout.getId() will be null, and calling .toHexString() on it will cause an NPE. This prevents new layouts from being saved. You should add a null check for the ID before attempting to check rights on an existing entity.
public ReportLayout save(ReportLayout reportLayout) {
//Only check additional specific rights when updating layout
if (reportLayout.getId() != null) {
Optional.ofNullable(get(reportLayout.getId().toHexString())).ifPresent(entity -> checkLayoutRight(entity, WRITE_RIGHT));
}
return super.save(reportLayout);
}There was a problem hiding this comment.
No it cannot, id is always set even for a new object, this is ensured by the deserializer
| tableRegistry.register(EntityConstants.reportLayouts, new Table<>(reportLayoutCollection, ReportLayoutServices.REPORT_LAYOUT_RIGHT + "-read", false).withResultItemTransformer((reportLayout, session) -> { | ||
| reportLayout.layout = Map.of(); | ||
| return reportLayout; | ||
| })); |
There was a problem hiding this comment.
The table for reportLayouts is registered without any default filters to restrict access based on ownership or sharing status. This allows any user with the reportLayout-read permission to view the metadata (such as name, owner, and attributes) of all layouts in the system via the /table endpoint, bypassing the ownership filtering implemented in the /list endpoint. While the layout field itself is cleared, the exposure of metadata for private layouts is still a security concern.
There was a problem hiding this comment.
The table is created with restriction for the report layout read right, only authenticated user with this specific right can access the list of layout (stripped from the actual layout content). I don't see it as an issue in this case.
| Map<String, Object> layout; | ||
| boolean shared = false; |
There was a problem hiding this comment.
The fields layout and shared have package-private visibility. To enforce proper encapsulation and improve maintainability, they should be declared as private. This change will require updating any direct field access in other classes (e.g., in ReportLayoutPlugin) to use the public setter methods.
| Map<String, Object> layout; | |
| boolean shared = false; | |
| private Map<String, Object> layout; | |
| private boolean shared = false; |
There was a problem hiding this comment.
Or be public without getter
There was a problem hiding this comment.
Switch to public and Json creator/property annotations
| String username = getSession().getUser().getUsername(); | ||
| User user = getSession().getUser(); | ||
| String username = user.getUsername(); | ||
| ObjectId userId = user.getId(); |
There was a problem hiding this comment.
As mentioned in the framework PR, I don't think we support the update of the username
There was a problem hiding this comment.
We actually can rename any Step users including the "admin" user in the user's admin setting view, that's why I introduced this. For now I couldn't see other cases where renaming the user could cause a functional issue (this is also nothing new), as in most cases it is only used as displayed info or at execution time.
| Map<String, Object> layout; | ||
| boolean shared = false; |
There was a problem hiding this comment.
Or be public without getter
| public static final String FIELD_IS_SHARED = "shared"; | ||
|
|
||
| @NotNull | ||
| Map<String, Object> layout; |
There was a problem hiding this comment.
Should we prefer JsonObject?
There was a problem hiding this comment.
Replaced indeed with JsonObject which makes more sense.
| //Register entity | ||
| context.getEntityManager().register(new Entity<>(EntityConstants.reportLayouts, reportLayoutAbstractAccessor, ReportLayout.class)); | ||
| //Register Table, table only return layout metadata not the layout itself | ||
| tableRegistry.register(EntityConstants.reportLayouts, new Table<>(reportLayoutCollection, ReportLayoutServices.REPORT_LAYOUT_RIGHT + "-read", false).withResultItemTransformer((reportLayout, session) -> { |
There was a problem hiding this comment.
Why do we need a table?
There was a problem hiding this comment.
Simply to be coherent with other entities and be ready if a managed layouts view is required in the future. Do you see any issue keeping it?
There was a problem hiding this comment.
Not really. If we'll anyway need it in the future it doesn't hurt. Concerning the gemini comment below, it is not a security issue as we remove the content
| private void checkLayoutRight(ReportLayout reportLayout, String right) { | ||
| //If user is the owner, he is always allowed (if he has the base access right role) | ||
| User currentUser = this.getSession().getUser(); | ||
| if (!reportLayout.getCreationUserId().equals(currentUser.getId())) { |
There was a problem hiding this comment.
Will "preset" layouts have a creationUserId?
There was a problem hiding this comment.
Well preset layout was still an open point, i.e. are they handled by the BE or FE? We now decided to define them BE side, the model and the access right check have been modified accordingly. Presets won't have any creation/modification metadata but that should not impact the access right check.
| public void setShared(boolean shared) { | ||
| this.shared = shared; | ||
| @JsonCreator | ||
| public ReportLayout(@JsonProperty("layout") JsonObject layout, @JsonProperty(value = "visibility", defaultValue = "Private") ReportLayoutVisibility visibility) { |
There was a problem hiding this comment.
- We could use the constant declared above for "visibility"
- We define 2 default values: one here for deserialization, one below for null values.
|
|
||
| public List<ReportLayout> getAccessibleReportLayoutsDefinitions(String username) { | ||
| Or ownerOrShared = Filters.or(List.of(Filters.equals(FIELD_IS_SHARED, true), Filters.equals("creationUser", username))); | ||
| public List<ReportLayout> getAccessibleReportLayoutsDefinitions(String userId) { |
There was a problem hiding this comment.
Change to username as discussed
| public List<ReportLayout> getAccessibleReportLayoutsDefinitions(String username) { | ||
| Or ownerOrShared = Filters.or(List.of(Filters.equals(FIELD_IS_SHARED, true), Filters.equals("creationUser", username))); | ||
| public List<ReportLayout> getAccessibleReportLayoutsDefinitions(String userId) { | ||
| Or ownerOrShared = Filters.or(List.of(Filters.equals(FIELD_VISIBILITY, Preset.name()), Filters.equals(FIELD_VISIBILITY, Shared.name()), Filters.equals("creationUserId", userId))); |
| //Register entity | ||
| context.getEntityManager().register(new Entity<>(EntityConstants.reportLayouts, reportLayoutAbstractAccessor, ReportLayout.class)); | ||
| //Register Table, table only return layout metadata not the layout itself | ||
| tableRegistry.register(EntityConstants.reportLayouts, new Table<>(reportLayoutCollection, ReportLayoutServices.REPORT_LAYOUT_RIGHT + "-read", false).withResultItemTransformer((reportLayout, session) -> { |
There was a problem hiding this comment.
Not really. If we'll anyway need it in the future it doesn't hurt. Concerning the gemini comment below, it is not a security issue as we remove the content
| @Override | ||
| public void initializeData(GlobalContext context) throws Exception { | ||
| super.initializeData(context); | ||
| ReportLayoutAccessor reportLayoutAccessor = context.require(ReportLayoutAccessor.class); |
There was a problem hiding this comment.
Could be accessed as field (detail)...
| super.initializeData(context); | ||
| ReportLayoutAccessor reportLayoutAccessor = context.require(ReportLayoutAccessor.class); | ||
| ReportLayout existingDefaultLayout = reportLayoutAccessor.getReportLayoutPresetIfExists(DEFAULT_REPORT_LAYOUT); | ||
| try (InputStream resourceAsStream = this.getClass().getResourceAsStream("presets/DefaultLayout.json"); |
There was a problem hiding this comment.
Not sure to understand this logic. We'll we now always have a preset in the DB for the default layout? The content of the DefaultLayout.json seems to be dummy. My understanding was that the default layout is just a pointer to an existing layout (either "preset" or "shared"). No?
Update: just saw the comment in the ticket. Would propose to discuss this
| "layout": { | ||
| "widgets": [ | ||
| { | ||
| "id": "c4460688-41d9-4765-a260-d3bed46c4d50", |
There was a problem hiding this comment.
As discussed with Tim, these id are not required and should be removed
| @Produces(MediaType.APPLICATION_JSON) | ||
| @Secured(right = "{entity}-read") | ||
| public ReportLayoutJson exportLayout(@PathParam("id") String id) { | ||
| ReportLayout reportLayout = getEntity(id); |
There was a problem hiding this comment.
Not sure but I have the impression that this bypasses the checkLayoutRight. Shouldn't we use get()?
There was a problem hiding this comment.
Good catch I prefer to use getEntity which throw a clean error if the ID does not exists, but added an explicit call to checkLayoutRight now
No description provided.