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
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,11 @@ private static void putPersonal(Map<String, Object> p, RelationIntent relation,
return;
}
p.put("relationshipPersonal", "true");
if (relation.isPersonalReadOnly()) {
// The personal surface is see-only for the owner: the my controller's write methods
// 405 and the my pages drop New/Edit/Delete (parameterUtils -> the rest/UI templates).
p.put("relationshipPersonalReadOnly", "true");
}
p.put("relationshipIdentityProperty", targetIdentityProperty);
// The identity entity's display/label field - the personal controller's /me returns it so the
// personal pages can show "New/Edit <Doc> for <owner>". Falls back to the identity match field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ public class RelationIntent {
*/
private boolean personal;

/**
* When set together with {@link #personal}, the personal (my) surface is READ-ONLY for the owner:
* the generated {@code <Entity>MyController} exposes only the scoped reads (getAll / get / count)
* and its create/update/delete return 405, and the personal pages render without New / Edit /
* Delete. Use for records the owner may SEE but never author (a leave-balance account, a payslip);
* the regular (power) controller is unaffected. Ignored unless {@link #personal} is also true.
*/
private boolean personalReadOnly;

/**
* Marks this to-one relation as the OWNER of the record for the PARTNER surface: the generated
* partner REST controller scopes reads to the logged-in external partner's mapped identity record
Expand Down Expand Up @@ -262,6 +271,14 @@ public void setPersonal(boolean personal) {
this.personal = personal;
}

public boolean isPersonalReadOnly() {
return personalReadOnly;
}

public void setPersonalReadOnly(boolean personalReadOnly) {
this.personalReadOnly = personalReadOnly;
}

public boolean isPartner() {
return partner;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@ scope through their parent). The entity then gets an ADDITIONAL generated `<Enti
scoped to the logged-in user: reads filtered to the mapped identity record, the owner FK forced
server-side on writes, foreign records 404. A field marked `sensitive: true` (not the PK, the
identity field, or the owner FK) is stripped from personal responses and ignored on personal
writes - use it for billing rates and amounts the person must not see. The regular controller is
writes - use it for billing rates and amounts the person must not see. Add `personalReadOnly: true`
alongside `personal: true` to make the personal surface **see-only**: the generated `MyController`
serves the scoped reads but its create/update/delete return **405**, and the my pages drop
New/Edit/Delete - 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` /
`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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ public class ${name}MyController {
return scrub(requireMine(id));
}

#if($personalReadOnly)
// See-only personal surface (intent personalReadOnly): the owner may read their own records but
// never author them - the write endpoints exist only to refuse with 405 (the power controller
// remains the sole write path). Fixes the self-grant risk on owner-owned reference records.
@Post
@Documentation("Not allowed - this ${name} is read-only on the personal surface")
public ${name}Entity create(@Body ${name}Entity entity) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "This ${name} is read-only on your personal surface");
}

@Put("/{id}")
@Documentation("Not allowed - this ${name} is read-only on the personal surface")
public ${name}Entity update(@PathParam("id") #foreach($property in $properties)#if($property.dataPrimaryKey)${property.dataTypeJavaClass}#end#end id, @Body ${name}Entity entity) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "This ${name} is read-only on your personal surface");
}

@Delete("/{id}")
@Documentation("Not allowed - this ${name} is read-only on the personal surface")
public void deleteById(@PathParam("id") #foreach($property in $properties)#if($property.dataPrimaryKey)${property.dataTypeJavaClass}#end#end id) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "This ${name} is read-only on your personal surface");
}
#else
@Post
@Documentation("Create a ${name} of mine")
public ${name}Entity create(@Body ${name}Entity entity) {
Expand Down Expand Up @@ -150,6 +172,7 @@ public class ${name}MyController {
throw e;
}
}
#end

/**
* The current user's identity record id: the ${personalIdentityProperty} of the identity entity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@
#end

<div class="hbox gap-2" style="max-width: 900px">
#if($personalReadOnly)
<div class="grow"></div>
<button x-h-button data-variant="transparent" @click="goBack()"
x-text="T('$projectName:${tprefix}.defaults.back', 'Back')"></button>
#else
<button x-h-button data-variant="negative" x-show="mode === 'edit'" @click="deleteOpen = true"
x-text="T('$projectName:${tprefix}.defaults.delete', 'Delete')"></button>
<div class="grow"></div>
Expand All @@ -162,6 +167,7 @@
<span x-show="saving" x-h-spinner></span>
<span x-text="T('$projectName:${tprefix}.defaults.save', 'Save')"></span>
</button>
#end
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
<div x-h-toolbar data-variant="transparent">
<span x-h-toolbar-title class="shrink-0" x-text="'My ' + T('$projectName:${tprefix}.t.${dataName}_plural', '${menuLabel}')"></span>
<div x-h-toolbar-spacer></div>
#if(!$personalReadOnly)
<button x-h-button data-variant="primary" @click="newEntity()">
<i role="img" x-h-lucide data-lucide="plus"></i>
<span x-text="T('$projectName:${tprefix}.defaults.new', 'New')"></span>
</button>
#end
<!-- Export the own rows as CSV / print them (Save as PDF via the browser dialog). -->
<button x-h-button data-variant="outline" data-size="sm" :disabled="items.length === 0" @click="exportCsv()">
<i role="img" x-h-lucide data-lucide="download"></i><span x-text="T('$projectName:${tprefix}.defaults.export', 'Export')"></span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ export function process(model, parameters) {
e.personalIdentityLabel = p.relationshipIdentityLabel || p.relationshipIdentityProperty;
e.personalIdentityEntityClass = `gen.${javaGen}.data.${javaPerspective}.${p.relationshipEntityName}Entity`;
e.personalIdentityRepositoryClass = `gen.${javaGen}.data.${javaPerspective}.${p.relationshipEntityName}Repository`;
// See-only personal surface (intent personalReadOnly): the my controller's
// writes 405 and the my pages drop New/Edit/Delete.
e.personalReadOnly = !!p.relationshipPersonalReadOnly;
}
// partner (intent `partner: true`): the external-partner mirror of the personal
// owner - resolves the current external user through the TARGET's repository.
Expand Down Expand Up @@ -265,6 +268,7 @@ export function process(model, parameters) {
personalProperty: parent.personalProperty,
personalFkJavaClass: parent.personalFkJavaClass
};
e.personalReadOnly = !!parent.personalReadOnly; // children inherit the see-only mode
e.personalIdentityProperty = parent.personalIdentityProperty;
e.personalIdentityLabel = parent.personalIdentityLabel;
e.personalIdentityEntityClass = parent.personalIdentityEntityClass;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
/*
* 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
*/
// Generated from org/eclipse/dirigible/parsers/typescript/TypeScriptParser.g4 by ANTLR 4.13.2
package org.eclipse.dirigible.parsers.typescript;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
/*
* 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
*/
// Generated from org/eclipse/dirigible/parsers/typescript/TypeScriptParser.g4 by ANTLR 4.13.2
package org.eclipse.dirigible.parsers.typescript;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
/*
* 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
*/
// Generated from org/eclipse/dirigible/parsers/typescript/TypeScriptParser.g4 by ANTLR 4.13.2
package org.eclipse.dirigible.parsers.typescript;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
/*
* 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
*/
// Generated from org/eclipse/dirigible/parsers/typescript/TypeScriptParser.g4 by ANTLR 4.13.2
package org.eclipse.dirigible.parsers.typescript;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
/*
* 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
*/
// Generated from org/eclipse/dirigible/parsers/typescript/TypeScriptParser.g4 by ANTLR 4.13.2
package org.eclipse.dirigible.parsers.typescript;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ class IntentEmissionCoverageIT extends IntegrationTest {
relations:
- { name: Claim, kind: manyToOne, to: Claim, composition: true }

# personalReadOnly: a see-only personal surface - the owner reads their own Balance
# rows but the my controller's writes 405 (a record the back office grants, the
# person must never author - the self-grant guard).
- name: Balance
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: days, type: decimal }
relations:
- { name: Person, kind: manyToOne, to: Person, required: true, personal: true, personalReadOnly: true }

# documentItemsLayout: chat - the document master's line-items child renders as a
# conversation thread (x-h-chat bubbles + a composer) instead of the editable table;
# the body maps to the messageBody field, author/timestamp to the child's audit columns.
Expand Down Expand Up @@ -540,6 +550,17 @@ private void assertEmission() {
assertTrue(lineMy.contains("entity.Cost = null"),
"a sensitive field on a scope-inheriting child must be scrubbed from its personal controller");

// personalReadOnly: the scoped controller still serves reads but its write methods 405 -
// no repository.save on the personal surface (the power controller keeps writing).
String balanceMy = contentOf("gen/emission/api/balance/BalanceMyController.java");
assertTrue(balanceMy.contains("read-only on your personal surface"),
"personalReadOnly must emit the write refusal on the personal write methods");
assertTrue(balanceMy.contains("HttpStatus.FORBIDDEN"), "personalReadOnly write methods must refuse with 403 FORBIDDEN");
assertTrue(!balanceMy.contains("repository.save(entity)"),
"personalReadOnly must NOT emit a persisting create/update on the personal controller");
String balanceMyView = contentOf("gen/emission/views/my/Balance-list.html");
assertTrue(!balanceMyView.contains("newEntity()"), "personalReadOnly my list must not render the New button");

// assignee: personal - the BPMN assigns the task to the start-time-resolved owner and the
// trigger listener seeds that variable from the identity mapping.
String bpmn = contentOf("ClaimConfirm.bpmn");
Expand Down Expand Up @@ -1094,6 +1115,19 @@ private void assertRuntimeEnforcement() {
.body(org.hamcrest.Matchers.containsString("emission-test-partner-PartnerTicket")),
30);

// personalReadOnly: the scoped read serves 200 (the owner sees their own rows), but a write
// to the personal surface is refused 405 - the see-only guarantee at the outermost layer.
restAssuredExecutor.execute(() -> given().when()
.get(API + "/balance/BalanceMyController")
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Days\":5}")
.when()
.post(API + "/balance/BalanceMyController")
.then()
.statusCode(403));

assertBpmEventsRuntime();
}

Expand Down
Loading