Skip to content

Commit d2144e1

Browse files
dmealingclaude
andcommitted
fix(codegen): generated routes say they are unauthenticated in every port (#367)
The TypeScript route generators already said so, showed how to guard the mount, and offered `expose` for per-verb control. The C#, Java, Kotlin and Python route generators said nothing, so an adopter mounting them got open CRUD over every table with no warning. That is how the first real app ended up throwing route codegen away. Each now emits a doc comment on the generated register method / controller / router: - the endpoints are unauthenticated; - how to guard them in that framework (C#: map on a group with RequireAuthorization(); Spring: require authentication for the controller's path; FastAPI: include_router with a dependency); - a row-ownership rule cannot be expressed on a mount, so eject the generator (`dotnet meta eject`, `mvn metaobjects:eject`, `metaobjects eject`) and hand-write those endpoints. Non-TS REST templates are frozen for features, so they get no `expose` option; eject is the per-verb path there. The Kotlin example names the path in prose rather than a `/**` glob: Kotlin nests block comments, so `/*` inside a KDoc would leave the comment unclosed and the controller would not compile. The snapshot test compares text and the compile gate excludes controllers, so nothing would have caught it. Tests: C# codegen 569 pass / conformance 1117 pass; codegen-spring 297, codegen-kotlin 387 (snapshots updated, both compile gates green); Python 2145 pass, compile gate green. Showcase regenerated with each port's own CLI; `verify --codegen` in sync for C# and Python, site payload fresh. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 56f29a9 commit d2144e1

11 files changed

Lines changed: 161 additions & 17 deletions

File tree

‎examples/showcase/.metaobjects/.gen-state/.hashes.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
"generated/csharp/SubscriberBlurbPayload.g.cs": "6b1ce5b5a4018c647586d86125718c4764ae5d6eeb94e9545e8006a7ec2b2b6f",
55
"generated/csharp/SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472",
66
"generated/csharp/SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174",
7-
"generated/csharp/SubscriberRoutes.g.cs": "41058cfbeb017e6b145d0288721d40e1d27e54b9cd398d82a13dbd62b39c3fb8",
7+
"generated/csharp/SubscriberRoutes.g.cs": "4afe6a906696bbb7ce07466e99bd3419fb86449b3fa8bec54366cbc1112631f5",
88
"generated/python/Subscriber.py": "9dd4c0acea47f99d1daf79930f9de2e400f30780ebce6c63f023be528a72b762",
99
"generated/python/SubscriberBlurbPayload.py": "a5ef8a08e63efbe0f73e39a21a368cff5150c68cc8beeea6e8aab0c755f8bbdd",
1010
"generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5",
1111
"generated/python/subscriber_filter_allowlist.py": "b0414cc95ff3982c80a0e1a06839f090c0b423e41f5a94275df93426cea09df5",
1212
"generated/python/subscriber_names.py": "269637a02bf9b46f55658b58cb42f83196c03a877e1e2101337847fd406c1561",
13-
"generated/python/subscriber_router.py": "7cf221a042731a1f1c939fbd30db10414787bacbd59ff6dbe759669b045e6737",
13+
"generated/python/subscriber_router.py": "23ed325ce7d146172e137e8e320d98ed07c652f6fc9e3095132def01a61a1371",
1414
"generated/ts/Subscriber.queries.ts": "2a142561bd9bcc3cfb88e6c306aafc638e982d48b0d07f71a79d810be16adf14",
1515
"generated/ts/Subscriber.routes.ts": "887b0aa76d32cf12fa1d16f0eae52fbc795ca8d8a29588b7089b6eb478f0cd93",
1616
"generated/ts/Subscriber.ts": "f19eeac95d012580f1b9023d807a8b9c893fb1a2ee983cf085fe958b65cbaba9",

‎examples/showcase/generated/csharp/SubscriberRoutes.g.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ public static class SubscriberRoutes
2424
{
2525
};
2626

27+
/// <summary>Maps <c>Subscriber</c> REST routes. Unauthenticated — see remarks.</summary>
28+
/// <remarks>
29+
/// These endpoints are unauthenticated. Guard them by mapping on a group that requires
30+
/// authorization, e.g. <c>app.MapGroup("").RequireAuthorization().MapSubscriberRoutes();</c>
31+
/// A row-ownership rule ("only the owner may read this row") cannot be expressed on a mount:
32+
/// eject this generator with <c>dotnet meta eject</c> and hand-write those endpoints.
33+
/// </remarks>
2734
public static IEndpointRouteBuilder MapSubscriberRoutes(this IEndpointRouteBuilder app, string prefix = "/api")
2835
{
2936
app.MapGet(prefix + "/subscribers", async (HttpContext http, AppDbContext db) =>

‎examples/showcase/generated/python/subscriber_router.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
# @generated by metaobjects — DO NOT EDIT.
22
# Source metadata: Subscriber (acme::Subscriber)
33
# Extend in your own module (e.g. Subscriber_extra.py) — nothing imports it for you.
4-
"""GENERATED — REST router for Subscriber entity. Implements the cross-port API contract."""
4+
"""GENERATED — REST router for Subscriber entity. Implements the cross-port API contract.
5+
6+
Auth: these endpoints are unauthenticated. To guard them, include the router with a
7+
dependency, e.g. `app.include_router(router, dependencies=[Depends(require_auth)])`.
8+
9+
A row-ownership rule ("only the owner may read this row") cannot be expressed as a
10+
router dependency: eject this generator with `metaobjects eject` and hand-write
11+
those endpoints.
12+
"""
513

614
from __future__ import annotations
715

‎server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@ protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx)
6868
return GenerateStandardRoutes(entity, ctx);
6969
}
7070

71+
// #367 — a generated route file carries no auth by default; ASP.NET Core applies no
72+
// policy to a bare Map* call, and an adopter that doesn't notice ships it open. The XML
73+
// doc on the mount method is the one place a consumer reads before wiring it into their
74+
// host, so it says so and shows the fix rather than leaving it to a README nobody opens.
75+
private static void AppendUnauthenticatedRoutesDoc(StringBuilder sb, string cls, bool readOnly)
76+
{
77+
var verbs = readOnly ? "read endpoints" : "endpoints";
78+
sb.AppendLine($" /// <summary>Maps <c>{cls}</c> REST routes. Unauthenticated — see remarks.</summary>");
79+
sb.AppendLine(" /// <remarks>");
80+
sb.AppendLine($" /// These {verbs} are unauthenticated. Guard them by mapping on a group that requires");
81+
sb.AppendLine($" /// authorization, e.g. <c>app.MapGroup(\"\").RequireAuthorization().Map{cls}Routes();</c>");
82+
sb.AppendLine(" /// A row-ownership rule (\"only the owner may read this row\") cannot be expressed on a mount:");
83+
sb.AppendLine($" /// eject this generator with <c>dotnet meta eject</c> and hand-write those {verbs}.");
84+
sb.AppendLine(" /// </remarks>");
85+
}
86+
7187
protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenContext ctx)
7288
{
7389
var cls = CSharpNaming.Pascal(entity.Name);
@@ -157,6 +173,7 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte
157173
sb.AppendLine(" };");
158174
sb.AppendLine();
159175
AppendSortDefaultOrder(sb, entity, "SortDefaultDesc");
176+
AppendUnauthenticatedRoutesDoc(sb, cls, isProjection);
160177
sb.AppendLine($" public static IEndpointRouteBuilder Map{cls}Routes(this IEndpointRouteBuilder app, string prefix = \"/api\")");
161178
sb.AppendLine(" {");
162179

@@ -448,6 +465,7 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t
448465
AppendSortDefaultOrder(sb, st.Entity, $"{subClsName}SortDefaultDesc");
449466
}
450467

468+
AppendUnauthenticatedRoutesDoc(sb, baseCls, readOnly: false);
451469
sb.AppendLine($" public static IEndpointRouteBuilder Map{baseCls}Routes(this IEndpointRouteBuilder app, string prefix = \"/api\")");
452470
sb.AppendLine(" {");
453471

‎server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,16 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
344344

345345
appendRowToMapper(this, shortName, entity, readObj)
346346

347-
append("/** GENERATED — REST controller for ${shortName} entity. Implements the cross-port API contract. */\n")
347+
append("/**\n")
348+
append(" * GENERATED — REST controller for ${shortName} entity. Implements the cross-port API contract.\n")
349+
append(" *\n")
350+
// No `/**` glob in the example: Kotlin NESTS block comments, so a `/*` inside this
351+
// KDoc would open a comment that never closes and the file would not compile.
352+
append(" * Auth: these endpoints are unauthenticated. In your Spring Security config, require\n")
353+
append(" * authentication for `$routeBase` and every path under it (`requestMatchers` + `authenticated()`).\n")
354+
append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n")
355+
append(" * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.\n")
356+
append(" */\n")
348357
append("@RestController\n")
349358
append("@RequestMapping(\"$routeBase\")\n")
350359
// FR-036: `validator` is always injected (POST + present-PATCH-value enforcement).
@@ -771,7 +780,15 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
771780
for (field in scalarFields) append(" ${field.name} = row[$table.${field.name}],\n")
772781
append(")\n\n")
773782

774-
append("/** GENERATED — TPH discriminator-base controller for $shortName (polymorphic + per-subtype CRUD). */\n")
783+
append("/**\n")
784+
append(" * GENERATED — TPH discriminator-base controller for $shortName (polymorphic + per-subtype CRUD).\n")
785+
append(" *\n")
786+
// No `/**` glob in the example: Kotlin NESTS block comments (see emit()).
787+
append(" * Auth: these endpoints are unauthenticated. In your Spring Security config, require\n")
788+
append(" * authentication for `$routeBase` and every path under it (`requestMatchers` + `authenticated()`).\n")
789+
append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n")
790+
append(" * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.\n")
791+
append(" */\n")
775792
append("@RestController\n")
776793
append("@RequestMapping(\"$routeBase\")\n")
777794
// FR-036: the per-subtype PATCH tristate binds present values through the Spring-configured
@@ -1728,7 +1745,15 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
17281745
emitFilterPipeline(this, shortName, readObj, allowlistName, scalarFields)
17291746
appendRowToMapper(this, shortName, entity, readObj)
17301747

1731-
append("/** GENERATED — READ-ONLY REST controller for the ${shortName} projection. */\n")
1748+
append("/**\n")
1749+
append(" * GENERATED — READ-ONLY REST controller for the ${shortName} projection.\n")
1750+
append(" *\n")
1751+
// No `/**` glob in the example: Kotlin NESTS block comments (see emit()).
1752+
append(" * Auth: these read endpoints are unauthenticated. In your Spring Security config, require\n")
1753+
append(" * authentication for `$routeBase` and every path under it (`requestMatchers` + `authenticated()`).\n")
1754+
append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n")
1755+
append(" * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.\n")
1756+
append(" */\n")
17321757
append("@RestController\n")
17331758
append("@RequestMapping(\"$routeBase\")\n")
17341759
// No constructor at all: nothing here binds a request body, so injecting an

‎server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-controller/acme/blog/AuthorController.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,14 @@ private fun rowToAuthor(row: ResultRow): Author = Author(
273273
name = row[AuthorTable.name],
274274
)
275275

276-
/** GENERATED — REST controller for Author entity. Implements the cross-port API contract. */
276+
/**
277+
* GENERATED — REST controller for Author entity. Implements the cross-port API contract.
278+
*
279+
* Auth: these endpoints are unauthenticated. In your Spring Security config, require
280+
* authentication for `/api/authors` and every path under it (`requestMatchers` + `authenticated()`).
281+
* A row-ownership rule ("only the owner may read this row") can't be expressed in path
282+
* config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.
283+
*/
277284
@RestController
278285
@RequestMapping("/api/authors")
279286
class AuthorController(private val objectMapper: ObjectMapper, private val validator: Validator) {

‎server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthController.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,14 @@ private fun rowToAuth(row: ResultRow): Auth = Auth(
309309
priorAuthNumber = row[AuthTable.priorAuthNumber],
310310
)
311311

312-
/** GENERATED — TPH discriminator-base controller for Auth (polymorphic + per-subtype CRUD). */
312+
/**
313+
* GENERATED — TPH discriminator-base controller for Auth (polymorphic + per-subtype CRUD).
314+
*
315+
* Auth: these endpoints are unauthenticated. In your Spring Security config, require
316+
* authentication for `/api/auths` and every path under it (`requestMatchers` + `authenticated()`).
317+
* A row-ownership rule ("only the owner may read this row") can't be expressed in path
318+
* config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.
319+
*/
313320
@RestController
314321
@RequestMapping("/api/auths")
315322
class AuthController(private val objectMapper: ObjectMapper, private val validator: Validator) {

‎server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineController.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,14 @@ private fun rowToAuthLine(row: ResultRow): AuthLine = AuthLine(
272272
label = row[AuthLineTable.label],
273273
)
274274

275-
/** GENERATED — REST controller for AuthLine entity. Implements the cross-port API contract. */
275+
/**
276+
* GENERATED — REST controller for AuthLine entity. Implements the cross-port API contract.
277+
*
278+
* Auth: these endpoints are unauthenticated. In your Spring Security config, require
279+
* authentication for `/api/auth_lines` and every path under it (`requestMatchers` + `authenticated()`).
280+
* A row-ownership rule ("only the owner may read this row") can't be expressed in path
281+
* config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.
282+
*/
276283
@RestController
277284
@RequestMapping("/api/auth_lines")
278285
class AuthLineController(private val objectMapper: ObjectMapper, private val validator: Validator) {

‎server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-view/acme/report/SalesReportController.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,14 @@ private fun rowToSalesReport(row: ResultRow): SalesReport = SalesReport(
274274
totalCents = row[SalesReportTable.totalCents],
275275
)
276276

277-
/** GENERATED — READ-ONLY REST controller for the SalesReport projection. */
277+
/**
278+
* GENERATED — READ-ONLY REST controller for the SalesReport projection.
279+
*
280+
* Auth: these read endpoints are unauthenticated. In your Spring Security config, require
281+
* authentication for `/api/sales_reports` and every path under it (`requestMatchers` + `authenticated()`).
282+
* A row-ownership rule ("only the owner may read this row") can't be expressed in path
283+
* config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.
284+
*/
278285
@RestController
279286
@RequestMapping("/api/sales_reports")
280287
class SalesReportController {

‎server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,17 @@ protected void emit(MetaObject entity, Path outRoot) {
220220
src.append("import java.util.Map;\n");
221221
src.append("import java.util.Set;\n\n");
222222

223-
src.append("/** GENERATED — REST controller for ").append(shortName)
224-
.append(" entity. Implements the cross-port API contract. */\n");
223+
src.append("/**\n");
224+
src.append(" * GENERATED — REST controller for ").append(shortName)
225+
.append(" entity. Implements the cross-port API contract.\n");
226+
src.append(" *\n");
227+
src.append(" * <p>Auth: these endpoints are unauthenticated. Require authentication for this path in\n");
228+
src.append(" * your Spring Security config, e.g. {@code http.authorizeHttpRequests(a ->\n");
229+
src.append(" * a.requestMatchers(\"").append(routeBase).append("/**\").authenticated())}.\n");
230+
src.append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n");
231+
src.append(" * config — eject this generator with {@code mvn metaobjects:eject} and hand-write those\n");
232+
src.append(" * endpoints.\n");
233+
src.append(" */\n");
225234
src.append("@RestController\n");
226235
src.append("@RequestMapping(\"").append(routeBase).append("\")\n");
227236
src.append("public class ").append(controllerName).append(" {\n\n");
@@ -552,6 +561,13 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) {
552561
.append(" projection.\n");
553562
src.append(" * Implements the cross-port API contract: GET list + GET by id; every write\n");
554563
src.append(" * verb answers 405 {\"error\": \"method_not_allowed\"}.\n");
564+
src.append(" *\n");
565+
src.append(" * <p>Auth: these read endpoints are unauthenticated. Require authentication for this path\n");
566+
src.append(" * in your Spring Security config, e.g. {@code http.authorizeHttpRequests(a ->\n");
567+
src.append(" * a.requestMatchers(\"").append(routeBase).append("/**\").authenticated())}.\n");
568+
src.append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n");
569+
src.append(" * config — eject this generator with {@code mvn metaobjects:eject} and hand-write those\n");
570+
src.append(" * endpoints.\n");
555571
src.append(" */\n");
556572
src.append("@RestController\n");
557573
src.append("@RequestMapping(\"").append(routeBase).append("\")\n");
@@ -800,8 +816,18 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) {
800816
src.append("import java.util.Map;\n");
801817
src.append("import java.util.Set;\n\n");
802818

803-
src.append("/** GENERATED — TPH discriminator-base controller for ").append(shortName)
804-
.append(" (polymorphic collection + per-subtype CRUD). Implements the cross-port API contract. */\n");
819+
src.append("/**\n");
820+
src.append(" * GENERATED — TPH discriminator-base controller for ").append(shortName)
821+
.append(" (polymorphic collection + per-subtype CRUD).\n");
822+
src.append(" * Implements the cross-port API contract.\n");
823+
src.append(" *\n");
824+
src.append(" * <p>Auth: these endpoints are unauthenticated. Require authentication for this path in\n");
825+
src.append(" * your Spring Security config, e.g. {@code http.authorizeHttpRequests(a ->\n");
826+
src.append(" * a.requestMatchers(\"").append(routeBase).append("/**\").authenticated())}.\n");
827+
src.append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n");
828+
src.append(" * config — eject this generator with {@code mvn metaobjects:eject} and hand-write those\n");
829+
src.append(" * endpoints.\n");
830+
src.append(" */\n");
805831
src.append("@RestController\n");
806832
src.append("@RequestMapping(\"").append(routeBase).append("\")\n");
807833
src.append("public class ").append(controllerName).append(" {\n\n");

0 commit comments

Comments
 (0)