Policy-based authorization

เดิมทีแอปพลิเคชันของเรามีระบบล็อกอินเพียงอย่างเดียว ผู้ใช้ที่ล็อกอินแล้วสามารถเรียกใช้งาน API ได้ทั้งหมดโดยไม่จำกัดสิทธิ์ เราได้ปรับมาใช้ Policy-based authorization เพื่อเป็นการการตรวจสอบสิทธิ์ผู้ใช้ การปรับเปลี่ยนนี้ช่วยให้เราสามารถควบคุมการเข้าถึง API ได้อย่างละเอียดตามสิทธิ์ของแต่ละผู้ใช้ ลดความเสี่ยงจากการเรียกใช้งานโดยไม่ได้รับอนุญาต และทำให้ระบบสามารถขยายสิทธิ์ใหม่ ๆ ได้ง่ายในอนาคต

Schema Registration

ลงทะเบียน Schema session เพื่อให้ Policy-base เข้าถึง session และตรวจสอบสิทธิได้

builder.Services.AddAuth(builder.Configuration);

การกำหนดสิทธิ์

🧾 ตาราง: UM_PERMISSION_FUNCTION

ColumnTypeRequiredDescription
ROLE_IDNUMBERรหัสบทบาท (Role)
FUNCTION_IDNVARCHAR2(50)รหัสฟังก์ชัน เช่น UM-01
PERMISSIONNVARCHAR2(100)สิทธิการใช้งาน เช่น view,edit,delete

🧾 Stored Procedure: UM_USER_PERM_Q

CREATE OR REPLACE PROCEDURE ATLASX.UM_USER_PERM_Q
(
	PI_USER_ID IN NUMBER,
	
  PO_DATA         out SYS_REFCURSOR,

  PO_STATUS       out NVARCHAR2,
  PO_STATUS_MSG   out NVARCHAR2
)
IS
BEGIN
	PO_STATUS := 1;
  PO_STATUS_MSG := ''; 
   
   OPEN PO_DATA FOR
	SELECT 
	    UR.ROLE_ID,
	    PF.FUNCTION_ID,
	    PF.PERMISSION
	FROM UM_USER_ROLE UR
	JOIN UM_PERMISSION_FUNCTION PF
	    ON UR.ROLE_ID = PF.ROLE_ID
	WHERE UR.USER_ID = PI_USER_ID;
   	
EXCEPTION
    WHEN OTHERS THEN

        PO_STATUS := 0;
        PO_STATUS_MSG := TO_CHAR(SQLCODE) || '-' || SQLERRM;
       
END UM_USER_PERM_Q;

เมื่อกำหนดสิทธิแล้ว หลังจากที่ user login จะดึงสิทธิ์ user จาก UM_USER_PERM_Q จากนั้นจะได้ session พร้อมสิทธิที่กำหนดไว้จาก UM_PERMISSION_FUNCTION

var session = new SessionData
{
    UserId = userInfo.Id,
    Role = [], //[1,2,3]
    Permission = [], // { '1:UM-01 = view,edit'} // {"{roleId}:{functionId}" = "permission1,permission2"}
    ExpiresAt = DateTime.UtcNow.AddMinutes(30),
    IsRotated = false
};

using var queryResult = await userInfoRepository.GetUserPermissionsAsync(userInfo.Id, cancellationToken);

foreach (DataRow row in queryResult.DataTable.Rows)
{
    var role = row["ROLE_ID"].ToString();
    var functionId = row["FUNCTION_ID"].ToString();
    var permission = row["PERMISSION"].ToString();

    var key = $"{role}:{functionId}";

    if (session.Permission.ContainsKey(key))
    {
        session.Permission[key] += "," + permission;
    }
    else
    {
        session.Permission.Add(key, permission ?? "");
    }
}

await sessionService.CreateSession(sessionId, session, TimeSpan.FromMinutes(30));

การตรวจสอบสิทธิ์

เมื่อผู้ใช้ส่งคำขอ API พร้อม cookie session ระบบจะตรวจสอบว่าผู้ใช้มี FunctionId ที่ระบุใน AxAuthorize อยู่ใน Claims หรือไม่ และมีสิทธิ์อะไรบ้าง

// Session user login
var session = new SessionData
{
    UserId = userInfo.Id,
    Role = [], //[1,2,3]
    Permission = [], // { "1:UM-01" = "view,edit"} // {"{roleId}:{functionId}" = "permission1,permission2"}
    ExpiresAt = DateTime.UtcNow.AddMinutes(30),
    IsRotated = false
};

//...

// ตัวอย่าง AxAuthorizeAttribute ในการกำหนดสิทธิ Endpoint
[HttpPost("userinfo")]
[AxAuthorize("UM-01", "view", "edit")]
public IActionResult PostUser()
{
    // Your logic here...
    return Ok(new
    {
        success = true,
    });
}

การใช้งาน [AxAuthorize]

  1. พารามิเตอร์ตัวแรกคือ FunctionId เช่น “UM-01”
  2. พารามิเตอร์ตัวถัดไปคือสิทธิ์ที่ต้องการ จะรับด้วย params string[] เช่น “view”, “edit”, “create”, “delete”
[AxAuthorize("UM-01", "view", "edit")]

ตัวอย่าง

Required Login

[AxAuthorize()]
[HttpPost("userinfo")]
public IActionResult PostUser()

Required FunctionId “UM-01” โดยไม่กำหนด permisssion

// if(userFunctionId == "UM-01")

[AxAuthorize("UM-01")]
[HttpPost("userinfo")]
public IActionResult PostUser()

Required FunctionId “UM-01” Permission “view” หรือ “edit” ก็ได้

// if(userFunctionId == "UM-01" && new[] { "view", "edit" }.Any(((p)=> userPermissions.Contains(p))))

[AxAuthorize("UM-01", "view", "edit")]
[HttpPost("userinfo")]
public IActionResult PostUser()

Required FunctionId “UM-01” Permission “view” และ “edit”

// if(userFunctionId == "UM-01" && new[] { "view", "edit" }.All(p => userPermissions.Contains(p)))

[AxAuthorize("UM-01", "view")]
[AxAuthorize("UM-01", "edit")]
[HttpPost("userinfo")]
public IActionResult PostUser()

Required FunctionId “UM-01” Permission (“view” หรือ “create”) และ (“edit” หรือ “delete”)

// if(userFunctionId == "UM-01" && new[] { "view", "edit" }.Any(p => userPermissions.Contains(p)) && new[] { "edit", "delete" }.Any(p => userPermissions.Contains(p)))

[AxAuthorize("UM-01", "view", "create")]
[AxAuthorize("UM-01", "edit", "delete")]
[HttpPost("userinfo")]
public IActionResult PostUser()

ปรับแต่ง AxAuthorize

หากโครงการของคุณมีการเช็คสิทธิ์เพิ่มเติมนอกเหนือจากที่ AxAuthorize ได้เตรียมไว้ให้ คุณสามารถปรับแต่งได้ตามต้องการ และในตัวอย่างนี้เราจะปรับแต่ง ให้ AxAuthorize ลองรับมากกว่า 1 FunctionId เช่น [AxAuthorize([“UM-01”, “UM-02”, “UM-03”], “view”, “create”)]

ในการปรับนี้เราจะใช้ทั้งหมด 4 ไฟล์ ได้แก่

  1. AxAuthorizeAttribute.cs
    ทำหน้าที่ประกาศ metadata ให้ Policy รับรู้ว่า Function ที่จะกำหนดสิทธิ์ต้องการสิทธิ์อะไรบ้าง
  2. AxAuthorizationPolicyProvider.cs
    ทำหน้าที่อ่าน metadata จาก AxAuthorizeAttribute และเก็บไว้ที่ AxAuthorizationRequirement และส่งต่อไปหา AxAuthorizationHandler
  3. AxAuthorizationRequirement.cs
    ทำหน้าที่เป็น model เก็บค่าต่างๆ ที่เราสนใจ
  4. AxAuthorizationHandler.cs
    ทำหน้าที่เป็นตัวเช็คสิทธิ์หลัก Logic ต่างๆจะอยู่ที่นี่

ขั้นตอนการปรับ AxAuthorization ให้รองรับมากกว่า 1 FunctionId

1. ที่ AxAuthorizeAttribute.cs ปรับ parameter ให้รองรับ FunctionId มากกว่า 1

// Before
public AxAuthorizeAttribute(string functionId, params string[] permissions)
{
    Policy = $"{POLICY_PREFIX}{functionId}:{string.Join(":", permissions)}";
}

// After
public AxAuthorizeAttribute(string[] functionId, params string[] permissions)
{
    Policy = $"{POLICY_PREFIX}{string.Join(",", functionId)}:{string.Join(":", permissions)}";
}

2. ที่ AxAuthorizationRequirement.cs ปรับ FunctionId จาก string เป็น string[]

// Before
public sealed class AxAuthorizationRequirement(string functionId, string[] permissions) : IAuthorizationRequirement
{
    public string FunctionId { get; } = functionId;
    public string[] Permissions { get; } = permissions;
}

// After
public sealed class AxAuthorizationRequirement(string[] functionId, string[] permissions) : IAuthorizationRequirement
{
    public string[] FunctionId { get; } = functionId;
    public string[] Permissions { get; } = permissions;
}

3. ที่ AxAuthorizationPolicyProvider.cs

// Before
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
    if (policyName.StartsWith(POLICY_PREFIX, StringComparison.OrdinalIgnoreCase))
    {
        var policyBuilder = new AuthorizationPolicyBuilder("AxSession");

        // ตัด Prefix ออก (รวมถึงเครื่องหมาย : ตัวแรกถ้ามี)
        var rawData = policyName.Length > POLICY_PREFIX.Length
                      ? policyName[POLICY_PREFIX.Length..]
                      : string.Empty;

        if (string.IsNullOrEmpty(rawData))
        {
            // เคส [AxAuthorize()] - ไม่ส่ง parameter อะไรมาเลย
            // ส่ง Requirement แบบว่างๆ ไปเพื่อให้ Handler เช็คแค่ Login
            policyBuilder.AddRequirements(new AxAuthorizationRequirement(string.Empty, []));
        }
        else
        {
            // เคสที่มี parameter เช่น "UM-01:view:edit" หรือ "UM-01"
            var parts = rawData.Split(':', StringSplitOptions.RemoveEmptyEntries);

            var functionId = parts[0];
            var permissions = parts.Skip(1).ToArray();

            policyBuilder.AddRequirements(new AxAuthorizationRequirement(functionId, permissions));
        }

        return Task.FromResult<AuthorizationPolicy?>(policyBuilder.Build());
    }

    return _fallbackPolicyProvider.GetPolicyAsync(policyName);
}

// After
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
    if (policyName.StartsWith(POLICY_PREFIX, StringComparison.OrdinalIgnoreCase))
    {
        var policyBuilder = new AuthorizationPolicyBuilder("AxSession");

        // ตัด Prefix ออก (รวมถึงเครื่องหมาย : ตัวแรกถ้ามี)
        var rawData = policyName.Length > POLICY_PREFIX.Length
                      ? policyName[POLICY_PREFIX.Length..]
                      : string.Empty;

        if (string.IsNullOrEmpty(rawData))
        {
            // เคส [AxAuthorize()] - ไม่ส่ง parameter อะไรมาเลย
            // ส่ง Requirement แบบว่างๆ ไปเพื่อให้ Handler เช็คแค่ Login
            policyBuilder.AddRequirements(new AxAuthorizationRequirement([], []));
        }
        else
        {
            // เคสที่มี parameter เช่น "UM-01:view:edit" หรือ "UM-01"
            var parts = rawData.Split(':', StringSplitOptions.RemoveEmptyEntries);

            var functionId = parts[0].Split(',', StringSplitOptions.RemoveEmptyEntries).ToArray();
            var permissions = parts.Skip(1).ToArray();

            policyBuilder.AddRequirements(new AxAuthorizationRequirement(functionId, permissions));
        }

        return Task.FromResult<AuthorizationPolicy?>(policyBuilder.Build());
    }

    return _fallbackPolicyProvider.GetPolicyAsync(policyName);
}

4. ที่ AxAuthorizationHandler.cs ปรับ logic การตรวจสอบสิทธิ์

// Before
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AxAuthorizationRequirement requirement)
{
    if (context.User == null || !(context.User.Identity?.IsAuthenticated ?? false))
    {
        context.Fail();
        return Task.CompletedTask;
    }

    var functionId = requirement.FunctionId;
    var reqPermissions = requirement.Permissions;

    if (string.IsNullOrEmpty(functionId))
    {
        context.Succeed(requirement);
        return Task.CompletedTask;
    }

    var permissions = context.User.Claims
        .Where(c => c.Type == "permission")
        .Select(c => c.Value);

    if (reqPermissions == null || reqPermissions.Length == 0)
    {
        var hasAnyInFunction = permissions.Any(p =>
            p.Contains($":{functionId}:", StringComparison.OrdinalIgnoreCase) ||
            p.EndsWith($":{functionId}", StringComparison.OrdinalIgnoreCase));

        if (hasAnyInFunction)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }

    var hasAtLeastOne = reqPermissions.Any(reqPerm =>
        permissions.Any(p => p.EndsWith($":{functionId}:{reqPerm}",
            StringComparison.OrdinalIgnoreCase))
    );

    if (hasAtLeastOne)
    {
        context.Succeed(requirement);
    }

    return Task.CompletedTask;
}

// After
protected override Task HandleRequirementAsync(
    AuthorizationHandlerContext context,
    AxAuthorizationRequirement requirement)
{
    // 1. พื้นฐานที่สุด: ต้อง Login ก่อนเสมอ
    if (context.User == null || !(context.User.Identity?.IsAuthenticated ?? false))
    {
        context.Fail();
        return Task.CompletedTask;
    }

    var functionIds = requirement.FunctionId;
    var reqPermissions = requirement.Permissions;

    // เคสที่ 1: [AxAuthorize()] - ไม่ระบุ FunctionId เลย
    if (functionIds == null || functionIds.Length == 0)
    {
        context.Succeed(requirement);
        return Task.CompletedTask;
    }

    var userPermissions = context.User.Claims
        .Where(c => c.Type == "permission")
        .Select(c => c.Value)
        .ToList();

    // เคสที่ 2: [AxAuthorize(["UM-01", "UM-02"])] - ระบุแค่ FunctionIds แต่ไม่ระบุ Permission เฉพาะเจาะจง
    // เช็คว่า User มีสิทธิ์ใดๆ ในฟังก์ชันใดฟังก์ชันหนึ่งในลิสต์นี้หรือไม่
    if (reqPermissions == null || reqPermissions.Length == 0)
    {
        var hasAnyInFunctions = functionIds.Any(fId =>
            userPermissions.Any(p =>
                p.Contains($":{fId}:", StringComparison.OrdinalIgnoreCase) ||
                p.EndsWith($":{fId}", StringComparison.OrdinalIgnoreCase))
        );

        if (hasAnyInFunctions)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }

    // เคสที่ 3: [AxAuthorize(["UM-01", "UM-02"], "view", "edit")] - ระบุครบ
    // เช็คแบบ OR: มี Permission ใดใน Function ใด ก็ถือว่าผ่าน
    var hasMatch = functionIds.Any(fId =>
        reqPermissions.Any(reqPerm =>
            userPermissions.Any(p => 
                p.EndsWith($":{fId}:{reqPerm}", StringComparison.OrdinalIgnoreCase))
        )
    );

    if (hasMatch)
    {
        context.Succeed(requirement);
    }

    return Task.CompletedTask;
}

แค่นี้คุณก็สามารถตรวจสอบสิทธิ์แบบ FunctionId มากกว่า 1 ได้แล้ว