Private Docs

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/2026POST /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
IActionConstraintroute ไม่ 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. ลำดับงาน

  1. รอ PR #3050 merge เข้า development ก่อน แล้วแตก branch · เขียนโค้ด 3 ไฟล์ใน Backend_Package + test แล้ว bump src/package.csproj เป็น 10.32.0 พร้อม CHANGELOG.md
  2. publish ขึ้น feed eximth ตาม checklist ของ repo
  3. bump SupApp_util_lib ใน service ที่จะทำ แล้วเติม AddUnexposedRoutes() หนึ่งบรรทัดใน Program.cs — merge ขั้นนี้ได้เลยเพราะยังไม่มี attribute ที่ไหน พฤติกรรมจึงไม่เปลี่ยน
  4. ไล่แปะ [NotExposed] ทีละเส้นตามรายการ NONE ของ service นั้น — ทีละ service ไม่ใช่ทีเดียวทั้งหมด และคัดรายการตามข้อ 7 ก่อนแปะเสมอ
  5. deploy dev ก่อน แล้วดูว่ามีอะไรพังไหม ค่อยขึ้น uat

7. ข้อควรระวัง

  1. 🔴 แปะแล้วตัดทุกทาง รวม S2S ที่ยิง svc.cluster.local โดยตรง — ห้ามแปะบน endpoint ในถัง BE เด็ดขาด · ถ้าเผลอแปะ อาการคือ 404 ที่ caller ฝั่งเรียก ไม่มี compile error ไม่มีอะไรเตือนตอน build · อาการ 404 ใช้ได้เฉพาะตอน RedisUserInfo:EnforceAppContext ยังปิด — gate ของ PR #3050 ถือว่า request ที่ไม่ match route ไหนเลย “อยู่ในขอบเขต” และตัดสินก่อนถึงตัว 404 ⇒ เมื่อ service เปิด EnforceAppContext route ที่ถูกลบจะตอบ 401/400/503 จาก gate แทน 404 · เวลาไล่ปัญหาให้ดู config ตัวนั้นด้วย
  2. 🔴 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 ต้องคัดเส้นนี้ออกก่อน
  3. ไม่มีทางถอยนอกจาก deploy ใหม่ — ไม่มี config ให้ปิด ⇒ เส้นที่แปะผิดต้องถอด attribute แล้ว build และ deploy ใหม่เท่านั้น · นี่คือราคาที่จ่ายแลกกับการไม่มีสวิตช์ และเป็นเหตุผลที่ต้องไล่ทีละ service
  4. [NotExposed] แทน [ApiExplorerSettings(IgnoreApi = true)] ได้ในตัว — แต่ Lane BE ห้ามใช้ตัวนี้ เพราะมันลบ route ทิ้ง ซึ่งจะตัด caller ที่เป็น backend ด้วย · Lane BE ใช้ [ApiExplorerSettings] ตามเดิม
  5. 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 เสมอ
  6. net462 build ไม่ได้บน Linux agent — repo ไม่มี Microsoft.NETFramework.ReferenceAssemblies · test ด้วย -p:TestTargetFrameworks=net10.0 -p:GeneratePackageOnBuild=false ตามที่ repo เตรียมไว้
  7. #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 ตั้งต้น ไม่ใช่ตัวเลขที่จำมา