Skip to content
Merged
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
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal
putPartner(fkProperty, relation,
target == null || target.getIdentity() == null ? null : IntentNaming.pascalCase(target.getIdentity()),
target == null ? null : labelFieldName(target), true);
putInheritedPersonalReadOnly(fkProperty, relation, composition);
properties.add(fkProperty);
relations.add(relationLink(name, relation, target, targetPerspective));
}
Expand Down Expand Up @@ -1917,6 +1918,24 @@ private static void putPersonal(Map<String, Object> p, RelationIntent relation,
(targetIdentityLabel == null || targetIdentityLabel.isBlank()) ? targetIdentityProperty : targetIdentityLabel);
}

/**
* Emit the see-only marker on the composition edge a child inherits its personal scope through
* ({@code personalReadOnly: true} without {@code personal: true} - dirigible #7340). The parent's
* own personal surface stays writable; the child's generated {@code MyController} refuses every
* write with 403 and its personal pages render no write affordance. Only the entity's OWNING
* composition carries it - a later composition is emitted as a plain association, which is exactly
* what the {@code composition} flag here says, and the parser refuses the key on any other edge.
*
* @param p the FK property being emitted
* @param relation the relation
* @param composition whether this relation is the entity's owning composition edge
*/
private static void putInheritedPersonalReadOnly(Map<String, Object> p, RelationIntent relation, boolean composition) {
if (composition && relation.isPersonalReadOnly() && !relation.isPersonal()) {
p.put("relationshipPersonalReadOnly", "true");
}
}

/**
* Emit the partner-owner attributes for a relation that declares {@code partner: true} - the exact
* mirror of {@link #putPersonal} for the external Partner shell. The generated partner REST
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,7 @@ private static Set<String> validateEntities(IntentModel model, Set<String> usesA
+ "] is marked composition but only a manyToOne/oneToOne relation can be a composition");
}
validateWhenMasterDeleted(entity, relation, issues);
validateInheritedPersonalReadOnly(entity, relation, byName, issues);
boolean crossModel = relation.isCrossModel();
if (crossModel) {
// A cross-model relation references an entity owned by another intent model declared in
Expand Down Expand Up @@ -4652,6 +4653,50 @@ private static void validateWhenMasterDeleted(EntityIntent entity, RelationInten
}
}

/**
* {@code personalReadOnly: true} on a relation that does NOT declare {@code personal: true}: the
* composition edge a child inherits its personal scope through, opting that child's personal
* surface out of writes while the parent's own stays writable (dirigible #7340). The scope still
* comes from the parent; the writes do not - which is what a user-authored header whose lines only
* an engine writes needs (a leave request whose day rows a delegate charges against an
* entitlement). Anywhere else the key would be carried nowhere, so it is refused rather than
* silently dropped: it must sit on a composition, on the entity's FIRST one (every later
* composition is emitted as a plain association, so nothing would read it), and on a child that
* really does inherit a personal surface through that parent.
*
* @param entity the entity declaring the relation
* @param relation the relation
* @param byName the declared entities of this model, by name
* @param issues the issue list to add to
*/
private static void validateInheritedPersonalReadOnly(EntityIntent entity, RelationIntent relation,
java.util.Map<String, EntityIntent> byName, List<String> issues) {
if (!relation.isPersonalReadOnly() || relation.isPersonal()) {
return;
}
String subject = "entity [" + entity.getName() + "] relation [" + relation.getName() + "]";
if (!relation.isComposition()) {
issues.add(subject + " declares personalReadOnly but neither personal: true nor composition: true - declare it alongside"
+ " personal: true to make this entity's own personal surface see-only, or on the composition relation the entity"
+ " inherits its personal scope through to make the inherited one see-only");
return;
}
for (RelationIntent candidate : entity.getRelations()) {
if (candidate.isComposition()) {
if (candidate != relation) {
issues.add(subject + " declares personalReadOnly but the entity's owning composition is [" + candidate.getName()
+ "] - only the first composition carries the inherited personal scope, so declare it there");
return;
}
break;
}
}
if (!hasPersonalSurface(byName, byName.get(relation.getTo()), new HashSet<>())) {
issues.add(subject + " declares personalReadOnly but its master [" + relation.getTo()
+ "] has no personal surface to inherit - there is no personal surface here to make see-only");
}
}

/**
* {@code leafOnly: true} restricts a to-one relation to leaf nodes of its target's hierarchy, so
* the target must declare one. A same-model target is checked here; a cross-model target is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,14 @@ serves the scoped reads but its create/update/delete return **403**, and the my
write affordance at all - no New on the list, no Save/Delete on the form or the document, and no
Add on a child panel or on the document's items - for records the owner may view but never author
(a leave-balance account, a payslip); the regular (power) controller still writes them normally.
The regular controller is unaffected. Sensitivity propagates to derived fields automatically: a rollup target (`op: sum` /
The regular controller is unaffected. The same key on a CHILD's **composition** relation makes only
that child's inherited surface see-only while the parent it inherits the scope from stays writable -
the scope still comes from the parent, the writes do not - which is the shape of a header the person
authors whose lines only an engine writes (a leave request whose day rows a delegate charges against
an entitlement): the child's `MyController` 403s and the parent's my/document page renders no Add on
that items panel, no row actions and no Add on that child panel. It is refused anywhere it would be
carried nowhere - on a plain association, on a second composition, or on a child whose master has no
personal surface to inherit. Sensitivity propagates to derived fields automatically: a rollup target (`op: sum` /
`latest`) whose `of:` child field is sensitive, and an `aggregate: true` master field fed by a
same-named sensitive item field, are treated as sensitive whenever their entity has a personal
surface (own `personal:` relation, or scope inherited through a composition parent chain) - the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2184,6 +2184,48 @@ void identityPersonalAndSensitiveFlowIntoTheModel() {
assertNull(propertyByName(entityByName(entities, "VacationRequest"), "Note").get("sensitiveProperty"));
}

/**
* {@code personalReadOnly: true} on the composition edge a child inherits its personal scope
* through (dirigible #7340) marks that edge see-only - the child's generated MyController refuses
* every write and its personal pages offer none - while the master's own personal surface, whose
* header the person really does author, stays writable.
*/
@Test
void aCompositionChildCanBeSeeOnlyWhileItsMasterStaysWritable() {
String yaml = """
name: hr
entities:
- name: Employee
identity: email
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: email, type: string, required: true, unique: true, length: 320 }
- name: VacationRequest
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string, length: 400 }
relations:
- { name: Employee, kind: manyToOne, to: Employee, required: true, personal: true }
- name: VacationRequestItem
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: day, type: date }
relations:
- { name: Request, kind: manyToOne, to: VacationRequest, composition: true, required: true,
personalReadOnly: true }
""";
List<Map<String, Object>> entities = entities(EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "hr"));

Map<String, Object> child = propertyByName(entityByName(entities, "VacationRequestItem"), "Request");
assertEquals("true", child.get("relationshipPersonalReadOnly"), "the owning composition edge carries the see-only marker");
assertNull(child.get("relationshipPersonal"), "the child owns no personal relation - the scope still comes from the master");
// The master's own surface is untouched: it declares personal without personalReadOnly.
Map<String, Object> owner = propertyByName(entityByName(entities, "VacationRequest"), "Employee");
assertEquals("true", owner.get("relationshipPersonal"));
assertNull(owner.get("relationshipPersonalReadOnly"));
}

/**
* {@code visibleTo:} is emitted as the model's own per-property read AND write roles - the pair the
* generated controllers already enforce - so the allow-list reaches the runtime through the same
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.components.intent.parser;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

/**
* {@code personalReadOnly: true} on the composition edge a child inherits its personal scope
* through (dirigible #7340): the scope still comes from the parent, the writes do not. The key is
* refused anywhere it would be carried nowhere, because a silently dropped access declaration reads
* as a grant.
*/
class InheritedPersonalReadOnlyIntentTest {

private static final String YAML = """
name: hr
entities:
- name: Employee
identity: email
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: email, type: string, required: true, unique: true, length: 320 }
- name: VacationRequest
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
relations:
- { name: Employee, kind: manyToOne, to: Employee, required: true, personal: true }
- name: VacationDay
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: day, type: date }
relations:
- { name: Request, kind: manyToOne, to: VacationRequest, composition: true, required: true, personalReadOnly: true }
""";

@Test
void onTheOwningCompositionItParses() {
assertTrue(IntentParser.parse(YAML)
.getEntities()
.get(2)
.getRelations()
.get(0)
.isPersonalReadOnly());
}

/** The parent it inherits the scope from keeps its own writable personal surface. */
@Test
void theMastersOwnPersonalSurfaceIsUntouched() {
assertFalse(IntentParser.parse(YAML)
.getEntities()
.get(1)
.getRelations()
.get(0)
.isPersonalReadOnly());
}

/** Without the key the child stays writable - an existing model parses byte-identically. */
@Test
void omittedParses() {
assertFalse(IntentParser.parse(YAML.replace(", personalReadOnly: true", ""))
.getEntities()
.get(2)
.getRelations()
.get(0)
.isPersonalReadOnly());
}

/** A plain association carries no inherited scope, so the key there would lock nothing. */
@Test
void onAPlainAssociationItIsRejected() {
assertIssue(YAML.replace("composition: true, ", ""), "declares personalReadOnly but neither personal: true nor composition: true");
}

/** Only the FIRST composition is the edge the scope travels; a later one is a plain association. */
@Test
void onASecondCompositionItIsRejected() {
String yaml = YAML.replace(
"- { name: Request, kind: manyToOne, to: VacationRequest, composition: true, required: true, personalReadOnly: true }",
"- { name: Request, kind: manyToOne, to: VacationRequest, composition: true, required: true }\n"
+ " - { name: Batch, kind: manyToOne, to: VacationRequest, composition: true, personalReadOnly: true }");
assertIssue(yaml, "the entity's owning composition is [Request]");
}

/** A master with no personal surface leaves nothing here to make see-only. */
@Test
void withoutAPersonalSurfaceToInheritItIsRejected() {
assertIssue(YAML.replace(", personal: true", ""), "has no personal surface to inherit");
}

private static void assertIssue(String yaml, String expected) {
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml));
assertTrue(ex.getMessage()
.contains(expected),
"expected issue containing [" + expected + "] but got: " + ex.getMessage());
}
}
Loading
Loading