Package — ปิด endpoint ที่ routing table ไม่ใช่แค่ซ่อนจาก spec
กลไกใน SupApp_util_lib ที่ลบ endpoint ในถัง NONE ออกจาก ASP.NET routing table ตอน startup — แปะ attribute แล้วปิดเลย ไม่มีโหมดไม่มีสวิตช์
อัปเดต: 2026-09-09
Package — ปิด endpoint ที่ routing table ไม่ใช่แค่ซ่อนจาก spec
เอกสารพี่น้องของ API Surface · ไฟล์นั้นตอบว่า เส้นไหนควรถูกลงทะเบียนบน APIM · ไฟล์นี้ตอบว่า จะปิดเส้นที่ไม่ควรเปิดด้วยกลไกอะไร
ยังไม่ลงมือ — เอกสารนี้เขียนไว้ให้ review ก่อน
รอบแก้ที่สาม 09/09/2026 — POST /sentinel/internal/resolve-session แก้ถังจาก NONE เป็น BE ·
มันมี backend caller จริง (APIM policy) เพียงแต่เรียกตรงไป internal host ไม่ผ่าน operation บน APIM
⇒ จัดการด้วย [ApiExplorerSettings] ของ Lane BE ห้ามใช้ [NotExposed]
รอบแก้ที่สอง 09/09/2026 ตามมติ — ตัดโหมดทิ้งทั้งหมด ไม่มี LogOnly ไม่มี Enforce ไม่มี config key ·
แปะ attribute = ปิด route ทันที เพราะเป้าหมายคือบังคับใช้กับเส้นที่ไม่ได้ใช้จริงอยู่แล้ว ·
เหลือ 3 ไฟล์ ไม่มี options ไม่มี middleware ไม่ต้องแก้ IaC
รอบแก้แรก 09/09/2026 ตาม review — เลขเวอร์ชันขยับเป็น 10.32.0 เพราะ 10.31.0 ถูก PR #3050 จองไปแล้ว ·
แก้เหตุผลที่เลือก IApplicationModelConvention (ของเดิมอ้างว่า IActionModelConvention มองไม่เห็น controller ซึ่งไม่จริง) ·
เปลี่ยนหลักฐานเรื่อง Minimal API จากการอ้าง §7 ของเอกสารหลัก (ซึ่งเป็นเรื่อง ingress) เป็นผล grep จริง ·
เพิ่มข้อควรระวังว่าอาการ 404 จะเปลี่ยนเมื่อ service เปิด EnforceAppContext ·
ตัวเลข test baseline แก้จาก 1463 เป็นผลรันจริง
1. ปัญหาที่ทำให้ต้องมีตัวนี้
แผนเดิมใช้ [ApiExplorerSettings(IgnoreApi = true)] ซึ่งซ่อน endpoint จาก OpenAPI spec ⇒ import
ขึ้น APIM ไม่ติด ⇒ client ภายนอกเรียกไม่ได้
แต่ attribute ตัวนั้น ไม่ได้แตะ routing table เลย — route ยัง map อยู่ใน ASP.NET และยังเรียกได้จากทุกที่ที่เข้าถึง pod ได้ ทั้งจากใน cluster และผ่าน ingress · มันคือ “ไม่โฆษณา” ไม่ใช่ “ปิด”
สำหรับ endpoint ในถัง NONE ที่เราตั้งใจว่า “ทำเผื่อไว้ ยังไม่มีใครใช้” การไม่โฆษณาอย่างเดียว ยังทิ้ง attack surface ไว้เท่าเดิม ⇒ ต้องการกลไกที่ทำให้ ไม่มี route อยู่ในตารางจริง
2. แบ่งงานเป็นสองเลน
| Lane BE — 32 เส้น | Lane NONE — 244 เส้น | |
|---|---|---|
| สภาพ | มี service อื่นเรียกจริง | ไม่พบผู้เรียก ทำเผื่อไว้ |
| ต้องการ | ซ่อนจาก APIM แต่ยังเรียกได้จากใน cluster | ปิดให้หมด ไม่ต้องเรียกได้จากทางไหนเลย |
| กลไก | [ApiExplorerSettings(IgnoreApi = true)] ตามเดิม | [NotExposed] ในเอกสารนี้ — ลบ route ทิ้ง |
| ลำดับ | ทำได้เลย ไม่ต้องรอ package | ต้องรอ package ออกก่อน |
ลำดับของ Lane NONE สลับไม่ได้ — ทำ package ให้เสร็จ → bump version → publish → apply เข้า service → แล้วค่อยไล่แปะ attribute ทีละเส้น
เหตุผลที่ต้องเรียงแบบนี้: ถ้าแปะ attribute ก่อนที่ package จะมี type นั้น โค้ดจะ compile ไม่ผ่าน · และถ้า apply package แต่ยังไม่แปะ attribute ที่ไหนเลย ก็ยังไม่มีอะไรเปลี่ยน จึงเป็นขั้นที่ปลอดภัยที่จะ merge ก่อน
3. เลือกกลไกไหน
ASP.NET Core มีหลายจุดที่แทรกได้ ตัวที่เหมาะกับงานนี้คือ MVC application model convention ซึ่งรันตอน startup ครั้งเดียว ก่อนที่ routing table จะถูกสร้าง
| ทางเลือก | ผล | ทำไมเลือก / ไม่เลือก |
|---|---|---|
IApplicationModelConvention + Actions.Remove(...) | action หายจาก application model ⇒ ไม่มีใน routing table และไม่มีใน spec | เลือกตัวนี้ ผลตรงไปตรงมาที่สุด ไม่เหลือร่องรอย |
IActionModelConvention + Selectors.Clear() | ไม่มี route selector ⇒ ไม่ถูก map | ได้ผลใกล้กัน แต่ action ยังอยู่ในโมเดล อ่านยากกว่าตอน debug |
IActionConstraint | route ไม่ match ตอน runtime | เสียค่าใช้จ่ายทุก request โดยไม่จำเป็น |
#if compile-time | ตัดโค้ดออกจริง | แข็งเกินไป เปิดกลับต้อง build ใหม่ทั้งชุด |
[ApiExplorerSettings(IgnoreApi = true)] | ซ่อนจาก spec เท่านั้น | ไม่ตอบโจทย์ Lane NONE — คือปัญหาในข้อ 1 |
repo มี precedent อยู่แล้ว — EncryptedFieldMapConvention : IActionModelConvention ที่
src/Middleware/PayloadEncryption/EncryptedFieldMapConvention.cs:23 ถูก register ผ่าน
services.Configure<MvcOptions>(options => options.Conventions.Add(...)) ที่
src/Extension/PayloadSecurityExtensions.cs:163 และ src/Extension/RequestLogExtensions.cs:70 ·
ของใหม่จะเดินตามรูปแบบเดียวกันทุกอย่าง ต่างแค่ใช้ IApplicationModelConvention ·
เหตุผลไม่ใช่ว่า IActionModelConvention ลบไม่ได้ — ActionModel.Controller ชี้กลับไปหา controller เจ้าของอยู่แล้ว
จึงลบผ่านมันได้ในทางเทคนิค — แต่เพราะ convention ระดับ application รันก่อน convention ระดับ controller/action
ทั้งหมดและถือทั้งสอง list เอง จึงลบ action และ controller ที่ว่างลงได้ในรอบเดียว และ convention ตัวถัดไป
(รวมถึง EncryptedFieldMapConvention) จะไม่มีวันเห็น action ที่ถูกลบไปแล้ว
ครอบคลุมครบทุก endpoint — grep ซ้ำ 09/09/2026 ทุก repo Backend_* บนเครื่อง (ไม่นับ obj/bin/test):
\.Map(Get|Post|Put|Patch|Delete)\s*\( ใน source ของ service จริง = 0 hit ทุก repo · ที่เจอ 20 hit ใน
Backend_SentinelGatewayService อยู่ใน other_project_for_demo/ ทั้งหมด (โปรเจกต์ demo ไม่ได้ deploy) ⇒
ทุก endpoint มาจาก controller ⇒ convention ของ MVC ครอบได้ 100% ไม่มีเส้นที่หลุด
4. ไม่มีโหมด — แปะแล้วปิดเลย
ตัดสินแล้ว 09/09: ไม่มี LogOnly ไม่มี Enforce ไม่มี config key · แปะ [NotExposed] บน action ไหน
action นั้นหายจาก application model ตอน startup ทันที ไม่มีสวิตช์ให้เปิดปิด
เหตุผล: attribute นี้จะถูกแปะเฉพาะเส้นที่ตั้งใจแล้วว่าไม่ได้ใช้จริง การมีโหมดให้ “ยังเรียกได้อยู่แต่ log ไว้ก่อน” คือการเพิ่มสวิตช์ที่ไม่มีใครจะกลับมาปิด และเพิ่ม middleware ที่วิ่งทุก request เพื่อประโยชน์ชั่วคราว ⇒ ตัดออกทั้งหมด เหลือกลไกเดียวที่อ่านแล้วเข้าใจทันทีว่าเกิดอะไรขึ้น
ผลที่ยอมรับพร้อมกับมตินี้ — ถัง NONE มาจาก static analysis ล้วน แปลว่า “ไม่พบ caller” ไม่ใช่ “ไม่มี caller” และตอนนี้ยังไม่มี telemetry มายืนยัน ⇒ เส้นที่ audit เดาผิดจะ 404 ทันทีที่ deploy กู้ได้ทางเดียวคือถอด attribute แล้ว deploy ใหม่ ไม่มีช่วงเก็บหลักฐานก่อน
ตัวลดความเสี่ยงที่เหลือคือ ทำ dev ก่อน uat · ไล่แปะทีละ service ไม่ใช่ทีเดียวทั้งหมด · และคัดรายการ NONE ให้แน่ก่อนแปะ โดยเฉพาะข้อควรระวังในข้อ 7
5. โค้ด
ทั้งหมดอยู่ใน Backend_Package (SupApp_util_lib) · development ณ 09/09/2026 (899aa2d) = 10.30.0
แต่ 10.31.0 ถูกจองแล้วโดย PR #3050 (app-context enforcement + permission primitives,
pullrequest/3050) ที่ยังไม่ merge
⇒ ของชุดนี้จะออกเป็น 10.32.0 และต้อง แตก branch จาก development หลัง #3050 merge แล้วเท่านั้น
ไม่งั้น CHANGELOG.md และ package.csproj ชนกันตอน merge
สามไฟล์ ไม่มี options ไม่มี middleware ไม่มี config key
5.1 attribute — src/Abstractions/Security/NotExposedAttribute.cs
#if NET9_0_OR_GREATER
namespace SupApp_util_lib.Abstractions.Security;
/// <summary>
/// Marks an action or an entire controller as not part of this service's exposed API surface:
/// built for later, or kept for a caller that no longer exists.
/// </summary>
/// <remarks>
/// The action is removed from the application model at startup, so it is absent from the routing table
/// and from the OpenAPI document alike. There is no switch: applying this attribute closes the route.
/// <para>
/// This is not an authorization mechanism. The endpoint stops existing for every caller — including
/// service-to-service traffic that reaches the pod directly and never passes through APIM or the ingress.
/// Do not put it on an endpoint another service calls.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class NotExposedAttribute : Attribute
{
/// <summary>
/// Free-text note for whoever reads this later — why the endpoint exists but is not exposed.
/// </summary>
public string? Reason { get; init; }
}
#endif
Inherited = true ทำให้ controller ที่สืบทอดจาก base controller ที่แปะไว้ได้ผลตามไปด้วย ·
AllowMultiple = false เพราะแปะซ้ำไม่มีความหมาย · Reason ไม่ถูกอ่านโดยโค้ด เก็บไว้ให้คนอ่านอย่างเดียว
5.2 convention — src/Middleware/ApiSurface/UnexposedRouteConvention.cs
#if NET9_0_OR_GREATER
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using SupApp_util_lib.Abstractions.Security;
namespace SupApp_util_lib.Middleware.ApiSurface;
/// <summary>
/// Removes every action marked <see cref="NotExposedAttribute"/> once at startup, before the routing
/// table is built. A controller left with no actions is dropped along with them.
/// </summary>
public sealed class UnexposedRouteConvention : IApplicationModelConvention
{
/// <inheritdoc />
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers.ToList())
{
var onController = controller.Attributes.OfType<NotExposedAttribute>().Any();
foreach (var action in controller.Actions.ToList())
{
if (onController || action.Attributes.OfType<NotExposedAttribute>().Any())
controller.Actions.Remove(action);
}
if (controller.Actions.Count == 0)
application.Controllers.Remove(controller);
}
}
}
#endif
จุดที่ตั้งใจ
.ToList()ทั้งสองชั้น — กำลังลบสมาชิกออกจาก collection ที่ loop อยู่ ถ้าไม่ copy ก่อนจะได้InvalidOperationException- ไม่ต้องแตะ
action.ApiExplorer.IsVisible— action ที่ถูกลบออกจาก model แล้วไม่มีทางไปโผล่ใน OpenAPI document ได้อยู่แล้ว ⇒[NotExposed]ตัวเดียวให้ผลของ[ApiExplorerSettings(IgnoreApi = true)]ไปในตัว ไม่ต้องแปะคู่กัน - attribute ที่ controller ครอบทุก action ในนั้น — แปะทั้ง class แล้วยกเว้นบาง action ไม่ได้ ·
ถ้าวันหนึ่งต้องการยกเว้นรายเส้น ค่อยเพิ่ม
[Exposed]คู่กัน ตอนนี้ยังไม่จำเป็น - ลบ controller ที่ว่าง — controller ที่ไม่มี action เหลือแล้วไม่มีประโยชน์ที่จะอยู่ในโมเดลต่อ
5.3 extension — src/Extension/ApiSurfaceExtensions.cs
#if NET9_0_OR_GREATER
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using SupApp_util_lib.Middleware.ApiSurface;
namespace SupApp_util_lib.Extensions;
/// <summary>Wiring for unexposed-route handling.</summary>
public static class ApiSurfaceExtensions
{
/// <summary>
/// Registers the startup convention that removes every action marked
/// <see cref="Abstractions.Security.NotExposedAttribute"/>.
/// </summary>
public static IServiceCollection AddUnexposedRoutes(this IServiceCollection services)
{
services.Configure<MvcOptions>(mvc => mvc.Conventions.Add(new UnexposedRouteConvention()));
return services;
}
}
#endif
namespace เป็น SupApp_util_lib.Extensions (พหูพจน์) ตามที่ KB ระบุว่าเป็นตัวที่ของใหม่ใช้ —
ใน repo มีทั้ง Extension และ Extensions ปนกันอยู่
5.4 ฝั่ง service เรียกยังไง
// Program.cs — บรรทัดเดียว ไม่ต้องแตะ pipeline ไม่ต้องแตะ config
builder.Services.AddUnexposedRoutes();
// แปะบน action ที่อยู่ในถัง NONE
[HttpPost("access")]
[NotExposed(Reason = "ทำเผื่อ access-control rollout ยังไม่มี caller ณ audit 2026-09-07")]
public async Task<IActionResult> CreateAccess(CreateAccessCommand command) => ApiMatch(await _mediator.Send(command));
ไม่มี config key ⇒ ไม่ต้องแก้ Backend_Iac และไม่มีเรื่อง parity ให้กังวล
6. ลำดับงาน
- รอ PR #3050 merge เข้า
developmentก่อน แล้วแตก branch · เขียนโค้ด 3 ไฟล์ในBackend_Package+ test แล้ว bumpsrc/package.csprojเป็น10.32.0พร้อมCHANGELOG.md - publish ขึ้น feed
eximthตาม checklist ของ repo - bump
SupApp_util_libใน service ที่จะทำ แล้วเติมAddUnexposedRoutes()หนึ่งบรรทัดในProgram.cs— merge ขั้นนี้ได้เลยเพราะยังไม่มี attribute ที่ไหน พฤติกรรมจึงไม่เปลี่ยน - ไล่แปะ
[NotExposed]ทีละเส้นตามรายการ NONE ของ service นั้น — ทีละ service ไม่ใช่ทีเดียวทั้งหมด และคัดรายการตามข้อ 7 ก่อนแปะเสมอ - deploy dev ก่อน แล้วดูว่ามีอะไรพังไหม ค่อยขึ้น uat
7. ข้อควรระวัง
- 🔴 แปะแล้วตัดทุกทาง รวม S2S ที่ยิง
svc.cluster.localโดยตรง — ห้ามแปะบน endpoint ในถัง BE เด็ดขาด · ถ้าเผลอแปะ อาการคือ 404 ที่ caller ฝั่งเรียก ไม่มี compile error ไม่มีอะไรเตือนตอน build · อาการ 404 ใช้ได้เฉพาะตอนRedisUserInfo:EnforceAppContextยังปิด — gate ของ PR #3050 ถือว่า request ที่ไม่ match route ไหนเลย “อยู่ในขอบเขต” และตัดสินก่อนถึงตัว 404 ⇒ เมื่อ service เปิดEnforceAppContextroute ที่ถูกลบจะตอบ 401/400/503 จาก gate แทน 404 · เวลาไล่ปัญหาให้ดู config ตัวนั้นด้วย - 🔴
POST /sentinel/internal/resolve-sessionเป็น BE ไม่ใช่ NONE — ห้ามแปะ[NotExposed]คนเรียกคือ APIM policy เอง (sentinel-facade-inbound-v6.xml:158) ยิงsend-requestตรงไป internal host ทุก request ที่ผ่าน facade · ลบ route ทิ้งเมื่อไหร่send-requestได้ 404 = ระบบ login ตายทั้งระบบ · เส้นนี้จัดการด้วย[ApiExplorerSettings(IgnoreApi = true)]ของ Lane BE รายละเอียดอยู่ในเอกสารหลักข้อ 8 ข้อ 3 · ตอนไล่ Lane NONE ของ SentinelGateway ต้องคัดเส้นนี้ออกก่อน - ไม่มีทางถอยนอกจาก deploy ใหม่ — ไม่มี config ให้ปิด ⇒ เส้นที่แปะผิดต้องถอด attribute แล้ว build และ deploy ใหม่เท่านั้น · นี่คือราคาที่จ่ายแลกกับการไม่มีสวิตช์ และเป็นเหตุผลที่ต้องไล่ทีละ service
[NotExposed]แทน[ApiExplorerSettings(IgnoreApi = true)]ได้ในตัว — แต่ Lane BE ห้ามใช้ตัวนี้ เพราะมันลบ route ทิ้ง ซึ่งจะตัด caller ที่เป็น backend ด้วย · Lane BE ใช้[ApiExplorerSettings]ตามเดิมdotnet packไม่ recompile — pack DLL ที่อยู่ในbin/Release/<tfm>ทันที ⇒ ถ้าเป็น incremental build เก่าจะ publish DLL ที่ไม่มีการเปลี่ยนแปลงจริง · เกิดมาแล้ว 3 ครั้งใน repo นี้ (10.6.7, 10.7.0, 10.7.2) ทุกครั้งต้อง bump version ใหม่เพื่อแก้ ⇒ clean ก่อน pack เสมอnet462build ไม่ได้บน Linux agent — repo ไม่มีMicrosoft.NETFramework.ReferenceAssemblies· test ด้วย-p:TestTargetFrameworks=net10.0 -p:GeneratePackageOnBuild=falseตามที่ repo เตรียมไว้#if NET9_0_OR_GREATERทั้งชุด ตามแบบเดียวกับ security middleware ที่ย้ายเข้ามาก่อนหน้านี้ · ทุกBackend_*เป็น net10 อยู่แล้วจึงไม่กระทบ
8. test ที่ต้องมี
- action ที่แปะ attribute หายจาก
ApplicationModel· action ที่ไม่แปะยังอยู่ครบ - attribute ที่ controller ทำให้ทุก action ในนั้นหาย
- controller ที่ action หายหมดถูกลบออกจาก
application.Controllers - controller ที่เหลือ action อยู่บ้างไม่ถูกลบ
AddUnexposedRoutes()ลง convention เข้าMvcOptions.Conventionsจริง
รัน dotnet test test/SupApp_util_lib.Tests.csproj -p:TestTargetFrameworks=net10.0 -p:GeneratePackageOnBuild=false
แล้วเทียบกับผลรันก่อนแก้ — ตัวเลข baseline ให้ยึดผลรันจริงบน branch ตั้งต้น ไม่ใช่ตัวเลขที่จำมา