FileEditViewTerminal
IT Issue Bootcamp - Visual Studio Code
day-5-hour-3.mdx

Day 5 / Hour 3

Authorization, RLS, and Admin

USER/ADMIN roles, profiles, created_by, RLS policies, admin page, and update status.

60 minutes
Admin can update status and users see only allowed data.

Authorization, RLS, and Admin

Day 5 - ชั่วโมงที่ 3: กำหนดสิทธิ์ USER และ ADMIN

Hour 2 ทำให้ระบบรู้ว่าใคร Login อยู่ ชั่วโมงนี้เราจะกำหนดว่าผู้ใช้แต่ละ Role อ่านหรือแก้ข้อมูลใดได้บ้าง

Supabase SQL Editor              สร้าง profiles, created_by และ RLS Policies
lib/auth.ts                      เพิ่มการอ่าน Role และ requireAdmin()
lib/issues.ts                    บันทึกเจ้าของ Issue
app/actions.ts                   ตรวจ User และ ADMIN
app/issues/page.tsx              ป้องกันหน้ารายการ
app/issues/[id]/page.tsx         ป้องกันหน้ารายละเอียด
components/IssueList.tsx         ซ่อนเครื่องมือจัดการในหน้า USER
app/admin/issues/page.tsx        สร้างใหม่
components/AppNav.tsx            เพิ่มลิงก์ Admin

Slide 1: จาก Authentication สู่ Authorization

Hour 2 ตอบคำถามแรกได้แล้ว:

Authentication → ผู้ใช้คนนี้คือใคร?

แต่การ Login สำเร็จไม่ได้หมายความว่าทุกคนควรทำงานได้เหมือนกัน เราจึงต้องตอบคำถามต่อไป:

Authorization → ผู้ใช้คนนี้ทำอะไรกับข้อมูลใดได้บ้าง?

Slide 2: กำหนดสิทธิ์ก่อนเขียน Code

Roleดูข้อมูลสร้าง Issueเปลี่ยน Status
USERเฉพาะ Issue ของตัวเองได้ไม่ได้
ADMINIssue ทั้งหมดได้ได้

ระบบจะตรวจสิทธิ์สามชั้น:

UI             → แสดงเฉพาะคำสั่งที่เหมาะกับผู้ใช้
Server Action  → ตรวจ Role ก่อนทำงานสำคัญ
RLS            → Database ตรวจทุก Row ก่อนอ่านหรือเขียน

Slide 3: เพิ่มเจ้าของให้แต่ละ Issue

ตอนนี้ Table issues ยังไม่รู้ว่าแต่ละ Row เป็นของใคร จึงยังสร้างกฎ “USER เห็นเฉพาะของตัวเอง” ไม่ได้

เมื่อ Login แล้ว auth.uid() จะคืน ID ของผู้ใช้ปัจจุบัน เราจะเก็บ ID เดียวกันไว้ใน issues.created_by

auth.users.id ──ผู้สร้าง──> issues.created_by
alter table public.issues
add column if not exists created_by uuid
references auth.users(id) on delete set null;
 
create index if not exists issues_created_by_idx
on public.issues (created_by);
  • references auth.users(id) บังคับให้ใช้ ID ของบัญชีที่มีอยู่จริง
  • on delete set null เก็บ Issue ไว้แม้บัญชีผู้สร้างถูกลบ
  • create index ... (created_by) สร้างตัวช่วยค้นหาที่ Column เจ้าของ เพราะ RLS ต้องเปรียบเทียบ created_by ทุกครั้งที่อ่าน Issue

เมื่อข้อมูลมีจำนวนมาก Index ช่วยให้ Database ไปยัง Row ของผู้ใช้ได้เร็วขึ้น แทนการไล่ตรวจทุก Row ใน Table

Issue เก่าจาก Day 4 จะมี created_by = null ซึ่งเป็นผลลัพธ์ที่ถูกต้อง เพราะข้อมูลเหล่านั้นถูกสร้างก่อนมีระบบผู้ใช้


Slide 4: แยกข้อมูล Login ออกจาก Role

Supabase สร้าง auth.users ให้อัตโนมัติเมื่อเราเพิ่มบัญชีผ่าน Authentication → Users Table นี้ดูแล Email, Password และตัวตนของผู้ใช้

Role เป็นข้อมูลเฉพาะของ Issue Tracker เราจึงสร้าง profiles แยกออกมาและใช้ ID เดียวกันเชื่อมสอง Table

auth.users  → ตัวตนและการ Login
profiles    → Role ของ Issue Tracker
create table if not exists public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  email text,
  role text not null default 'USER'
    check (role in ('USER', 'ADMIN')),
  created_at timestamptz not null default now()
);
  • id เป็นทั้ง Primary Key และ Foreign Key จึงมีได้หนึ่ง Profile ต่อหนึ่ง User
  • on delete cascade ลบ Profile ตามอัตโนมัติเมื่อลบบัญชีที่เชื่อมอยู่ใน auth.users จึงไม่เหลือ Profile ที่ไม่มีเจ้าของ
  • Role เริ่มต้นเป็น USER ซึ่งมีสิทธิ์น้อยกว่า
  • check ป้องกันค่าอื่นหรือการสะกด Role ผิด

Slide 5: สร้าง Profile และตั้ง ADMIN

บัญชีจาก Hour 2 อยู่ใน auth.users แล้ว แต่ profiles ที่เพิ่งสร้างยังไม่มีข้อมูล ขั้นตอนนี้จะคัดลอก ID และ Email มาสร้าง Profile

1. สร้าง Profile ให้บัญชีที่มีอยู่

insert into public.profiles (id, email, role)
select id, email, 'USER'
from auth.users
on conflict (id) do update
set email = excluded.email;

select ... from auth.users อ่านบัญชีที่สร้างไว้ ส่วน on conflict ทำให้รันซ้ำได้โดยไม่สร้าง Profile ซ้ำและไม่เปลี่ยน ADMIN กลับเป็น USER

2. ตั้งบัญชีผู้ดูแล

update public.profiles
set role = 'ADMIN'
where email = 'admin@example.com';

3. ตรวจผลลัพธ์

select id, email, role
from public.profiles
order by email;

ควรเห็น user@example.com เป็น USER และ admin@example.com เป็น ADMIN


Slide 6: ให้ผู้ใช้เห็นเฉพาะ Profile ตัวเอง

แอปต้องอ่าน Role ของผู้ใช้ แต่ไม่ควรเปิดให้ผู้ใช้เห็น Profile ของคนอื่นหรือแก้ Role ของตัวเอง

alter table public.profiles enable row level security;
 
revoke all on public.profiles from anon, authenticated;
grant select on public.profiles to authenticated;
 
drop policy if exists "users_can_view_own_profile"
on public.profiles;
 
create policy "users_can_view_own_profile"
on public.profiles
for select
to authenticated
using (id = (select auth.uid()));

อ่านคำสั่งตามลำดับ

คำสั่งหน้าที่
enable row level securityเปิดให้ Table ตรวจ RLS Policy ทุกครั้งที่เข้าถึงข้อมูล
revoke all ... from anon, authenticatedถอนสิทธิ์เดิมของทั้งผู้ที่ยังไม่ Login และผู้ที่ Login แล้ว เพื่อเริ่มกำหนดใหม่ให้ชัดเจน
grant select ... to authenticatedให้เฉพาะผู้ที่ Login แล้วมีสิทธิ์ส่งคำสั่งอ่าน Table
drop policy if existsลบ Policy ชื่อเดิมก่อน ทำให้รัน SQL ชุดนี้ซ้ำได้โดยไม่ชนชื่อ
for select to authenticatedใช้ Policy นี้เฉพาะการอ่านข้อมูลของผู้ที่ Login แล้ว
using (id = (select auth.uid()))อนุญาตเฉพาะ Row ที่ Profile ID ตรงกับ ID ใน Session
ยังไม่ Login → ไม่มีสิทธิ์อ่าน profiles
Login แล้ว    → อ่านได้เฉพาะ Row ที่ id ตรงกับ auth.uid()

Policy users_can_view_own_profile เป็น Policy ใหม่ที่เราสร้างใน Slide นี้ และเพราะไม่ได้ให้สิทธิ์ update ผู้ใช้จึงเปลี่ยนตัวเองเป็น ADMIN ผ่านแอปไม่ได้


Slide 7: สร้างกฎ SELECT สำหรับ Issue

กฎแรกควบคุม .select() โดย USER เห็น Row ที่ตัวเองสร้าง ส่วน ADMIN เห็นทุก Row

drop policy if exists "users_view_own_admins_view_all"
on public.issues;
 
create policy "users_view_own_admins_view_all"
on public.issues
for select
to authenticated
using (
  created_by = (select auth.uid())
  or exists (
    select 1
    from public.profiles
    where id = (select auth.uid())
      and role = 'ADMIN'
  )
);

อ่านเงื่อนไขเป็นภาษาง่าย ๆ:

เป็นเจ้าของ Issue หรือเป็น ADMIN → อ่าน Row นี้ได้
ไม่ตรงทั้งสองเงื่อนไข            → Database ไม่ส่ง Row นี้กลับมา

exists (...) ตรวจ Profile ของผู้ใช้ปัจจุบันว่ามี Role เป็น ADMIN หรือไม่ โดยผู้ใช้มีสิทธิ์อ่านเฉพาะ Profile ของตัวเองจาก Slide 6

ตอนนี้ Demo Policy เดิมยังเปิดอยู่ Policy ใหม่นี้จึงยังไม่จำกัดข้อมูลจริง เราจะปิด Demo Access หลังเตรียม Code และ Policy ทุกส่วนเสร็จใน Slide 18


Slide 8: สร้างกฎ INSERT สำหรับเจ้าของ Issue

ตอนสร้าง Issue แอปต้องส่ง created_by ที่ตรงกับผู้ใช้ใน Session Database จึงควรตรวจค่านี้ก่อนบันทึก

drop policy if exists "users_insert_own_issues"
on public.issues;
 
create policy "users_insert_own_issues"
on public.issues
for insert
to authenticated
with check (created_by = (select auth.uid()));

with check ตรวจ Row ใหม่ก่อนบันทึก ถ้า created_by ไม่ตรงกับผู้ใช้ที่ Login Database จะปฏิเสธ

created_by ตรงกับ auth.uid()       → บันทึกได้
created_by เป็น ID อื่นหรือเป็น null → ปฏิเสธ

Policy นี้ช่วยป้องกันทั้งกรณีที่แอปลืมส่งเจ้าของ และกรณีที่ผู้ใช้พยายามสร้าง Issue ในนามของคนอื่น


Slide 9: สร้างกฎ UPDATE สำหรับ ADMIN

USER มีหน้าที่แจ้งและติดตาม Issue ส่วนการเปลี่ยน Status เป็นหน้าที่ของ ADMIN

drop policy if exists "admins_update_issues"
on public.issues;
 
create policy "admins_update_issues"
on public.issues
for update
to authenticated
using (
  exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'ADMIN'
  )
)
with check (
  exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'ADMIN'
  )
);
  • using ตรวจว่า ADMIN เลือก Row เดิมมาแก้ได้หรือไม่
  • with check ตรวจอีกครั้งก่อนบันทึก Row หลังแก้
  • ไม่มี DELETE Policy เพราะระบบใช้ Status DONE แทนการลบ Issue

Slide 10: บันทึกเจ้าของตอนสร้าง Issue

Policy INSERT ต้องได้รับ created_by ที่ตรงกับ Session เราจึงนำ user.id จาก requireUser() ส่งต่อไปยัง createIssue()

Session → requireUser() → user.id → createIssue() → created_by

จุดที่ 1: แก้ createIssueAction() ใน app/actions.ts

export async function createIssueAction(formData: FormData) {  await requireUser();  const user = await requireUser();   const input = parseIssueInput(formData);  // เก็บ Validation เดิมไว้   await createIssue(input);  await createIssue(input, user.id);}

จุดที่ 2: เพิ่มสองบรรทัดใน createIssue() ที่ lib/issues.ts

เก็บ Promise<void>, Fields และ Error Handling เดิมไว้ แล้วเพิ่มเฉพาะบรรทัดที่ Highlight:

export async function createIssue(  input: NewIssueInput,  userId: string,): Promise<void> {  const supabase = await createClient();   const { error } = await supabase.from("issues").insert({    reporter_name: input.reporterName,    reporter_email: input.reporterEmail,    title: input.title,    description: input.description,    status: "OPEN",    created_by: userId,  });   if (error) {    throw new Error(`Failed to create issue: ${error.message}`);  }}

สิ่งที่เพิ่มมีเพียง:

  • userId: string ทำให้ Function รับ ID ของผู้ใช้จาก Server Action
  • created_by: userId บันทึก ID นั้นเป็นเจ้าของ Row ใหม่

อย่ารับ created_by จาก Hidden Input เพราะผู้ใช้แก้ค่าใน Browser ได้ ID ต้องมาจาก Session และ RLS จะตรวจซ้ำก่อนบันทึก

เราไม่เพิ่ม createdBy ใน Type Issue เพราะ UI ยังไม่ได้ใช้ Field นี้ Database ใช้ created_by สำหรับ RLS และการบันทึกเจ้าของโดยตรง


Slide 11: เพิ่ม Helper ตรวจสิทธิ์ ADMIN

จากโค้ดเดิม ไม่ต้องลบหรือแก้ getCurrentUser() และ requireUser() เราจะเพิ่มสามส่วน:

  1. UserRole กำหนดค่า Role ที่แอปรองรับ
  2. getCurrentUserRole() อ่าน Role จาก profiles
  3. requireAdmin() บังคับว่าต้อง Login และมี Role เป็น ADMIN

จุดที่ 1: เพิ่ม Type ใต้ Import เดิม

import { redirect } from "next/navigation";import { createClient } from "@/lib/supabase/server"; type UserRole = "USER" | "ADMIN";

จุดที่ 2: เพิ่มสอง Functions ต่อจาก requireUser()

export async function getCurrentUserRole(
  userId: string,
): Promise<UserRole | null> {
  const supabase = await createClient();
  const { data, error } = await supabase
    .from("profiles")
    .select("role")
    .eq("id", userId)
    .maybeSingle();
 
  if (error) {
    console.error("Failed to read user role:", error.message);
    return null;
  }
 
  if (data?.role === "USER" || data?.role === "ADMIN") {
    return data.role;
  }
 
  return null;
}
 
export async function requireAdmin() {
  const user = await requireUser();
  const role = await getCurrentUserRole(user.id);
 
  if (role !== "ADMIN") {
    redirect("/issues");
  }
 
  return user;
}
ไม่มี Session → requireUser() ส่งไป /login
มี Role USER  → requireAdmin() ส่งไป /issues
มี Role ADMIN → ทำงานต่อ

Role ถูกอ่านจาก Database ฝั่ง Server ไม่ได้เชื่อค่าจาก URL, Email หรือ Browser


Slide 12: ป้องกันหน้าที่อ่าน Issue

Hour 2 ป้องกัน /issues/new แล้ว แต่หน้ารายการและรายละเอียดก็ต้อง Login ก่อน Query เพราะ Slide 18 จะถอนสิทธิ์ของ anon

แก้ app/issues/page.tsx

import { IssueList } from "@/components/IssueList";import { getIssues } from "@/lib/issues";import { requireUser } from "@/lib/auth"; export default async function IssuesPage() {  await requireUser();  const issues = await getIssues();   // เก็บ JSX เดิมไว้}

แก้ app/issues/[id]/page.tsx

import { notFound } from "next/navigation";import { getIssueById } from "@/lib/issues";import { requireUser } from "@/lib/auth"; export default async function IssueDetailPage({  params,}: IssueDetailPageProps) {  await requireUser();  const { id } = await params;  const issue = await getIssueById(id);   // เก็บ notFound() และ JSX เดิมไว้}

Slide 13: ให้เฉพาะ ADMIN เปลี่ยน Status ได้

updateIssueStatusAction() เดิมตรวจว่า id และ status ถูกต้อง แต่ยังไม่ได้ตรวจว่า ใครเป็นคนเรียก Action ถ้า USER ส่ง Request เข้ามาเอง Code เดิมจะพยายาม Update Database

แก้สามจุดใน app/actions.ts

จุดที่แก้เหตุผล
Import requireAdminเพื่อเรียก Helper ที่สร้างใน Slide 11
await requireAdmin()ปฏิเสธ USER ก่อนอ่าน formData และ Update
revalidatePath("/admin/issues")ให้หน้า Admin แสดง Status ล่าสุด
import { requireUser } from "@/lib/auth";import { requireAdmin, requireUser } from "@/lib/auth"; export async function updateIssueStatusAction(  formData: FormData,): Promise<void> {  await requireAdmin();   const id = String(formData.get("id") ?? "").trim();  // เก็บ Validation และ Update เดิมไว้   revalidatePath("/issues");  revalidatePath("/admin/issues");}

เก็บ requireUser ไว้เพราะ createIssueAction() ยังใช้งานอยู่ ส่วน RLS จะตรวจ Role ซ้ำอีกชั้นก่อน Database แก้ Row


Slide 14: ให้ IssueList เลือกแสดงปุ่มจัดการ

IssueList จาก Day 4 แสดงปุ่มเปลี่ยน Status อยู่เสมอ แต่หลังแบ่งสิทธิ์แล้วสองหน้าต้องแสดงผลต่างกัน:

หน้า /issues        → USER ดูข้อมูลอย่างเดียว
หน้า /admin/issues  → ADMIN เห็นปุ่มเปลี่ยน Status

เราจึงเพิ่ม Prop canManage เพื่อให้ Parent บอกว่า Context นี้แสดงเครื่องมือจัดการได้หรือไม่

type IssueListProps = {  issues: Issue[];  canManage?: boolean;}; export function IssueList({ issues }: IssueListProps) {export function IssueList({  issues,  canManage = false,}: IssueListProps) {
  • canManage? เป็น Optional Prop จึงไม่บังคับให้ทุกหน้าต้องส่งค่า
  • canManage = false ทำให้หน้าที่ไม่ส่ง Prop แสดงรายการแบบอ่านอย่างเดียว
  • หน้า Admin จะส่ง <IssueList issues={issues} canManage /> เพื่อเปลี่ยนค่าเป็น true

Slide นี้เตรียมค่า canManage ก่อน ส่วน Slide 15 จะนำค่านี้ไปควบคุมหัว Column และปุ่มในแต่ละ Row


Slide 15: ซ่อน Column จัดการด้วย canManage

Slide ก่อนหน้าเป็นเพียงการเตรียม Prop ตอนนี้ให้นำ canManage มาใช้ใน JSX เพื่อเลือกว่าจะแสดง Column จัดการหรือไม่

  • canManage เป็น true → แสดงหัว Column และปุ่มเปลี่ยน Status
  • canManage เป็น false → ไม่สร้าง Element เหล่านี้บนหน้าเว็บ

จุดที่ 1: แทน <th> เดิมใน <thead>

<th className="px-3 py-3 font-semibold">จัดการ</th>{canManage && (  <th className="px-3 py-3 font-semibold">จัดการ</th>)}

จุดที่ 2: ครอบ <td> เดิมภายในแต่ละ Row

<td className="px-3 py-4">  {/* Forms และปุ่มเปลี่ยน Status เดิม */}</td>{canManage && (  <td className="px-3 py-4">    {/* เก็บ Forms และปุ่มเปลี่ยน Status เดิมทั้งหมดไว้ */}  </td>)}

ให้นำ <th> และ <td> เดิมมาครอบด้วยเงื่อนไข ไม่ต้องสร้าง Column ชุดที่สอง และต้องใช้เงื่อนไขเดียวกันทั้งสองจุดเพื่อให้หัวตารางตรงกับข้อมูล

การซ่อนปุ่มช่วยให้หน้า USER อ่านง่ายขึ้น แต่ Security จริงยังต้องตรวจซ้ำใน Server Action และ RLS


Slide 16: สร้างหน้า /admin/issues

import { IssueList } from "@/components/IssueList";
import { requireAdmin } from "@/lib/auth";
import { getIssues } from "@/lib/issues";
 
export default async function AdminIssuesPage() {
  await requireAdmin();
  const issues = await getIssues();
 
  return (
    <main className="mx-auto max-w-5xl px-6 py-8">
      <h1 className="text-2xl font-bold">Admin Issues</h1>
      <p className="mt-2 text-sm text-slate-600">
        ดู Issue ทั้งหมดและอัปเดตสถานะ
      </p>
 
      <div className="mt-6">
        <IssueList issues={issues} canManage />
      </div>
    </main>
  );
}
requireAdmin() → getIssues() → RLS ให้ ADMIN เห็นทุก Row → canManage แสดงปุ่ม

หาก USER พิมพ์ URL นี้โดยตรง requireAdmin() จะส่งกลับ /issues


Slide 17: แสดงลิงก์ Admin ตาม Role

เพิ่ม Role เข้าไปใน AppNav() เพื่อให้เฉพาะ ADMIN เห็นทางเข้าหน้าจัดการ

1. แก้ Import

import { getCurrentUser } from "@/lib/auth";import { getCurrentUser, getCurrentUserRole } from "@/lib/auth";

2. อ่าน Role ภายใน AppNav()

export async function AppNav() {  const user = await getCurrentUser();  const role = user ? await getCurrentUserRole(user.id) : null;   return (

3. แทรกลิงก์ Admin ก่อนลิงก์ New Issue

ภายในเงื่อนไข {user ? (...)} ให้แทรกโค้ดใหม่หลัง <> และก่อน <Link href="/issues/new">

{user ? (  <>    {role === "ADMIN" && (      <Link href="/admin/issues" className="font-semibold">        Admin      </Link>    )}     <Link href="/issues/new" className="font-semibold">      New Issue    </Link>    {/* เก็บ Email และปุ่ม Logout เดิมไว้ */}  </>) : (  // เก็บลิงก์ Login เดิมไว้)}

ลิงก์ New Issue, Email และปุ่ม Logout เดิมไม่ต้องลบ เพียงเพิ่มเงื่อนไขของ Admin ไว้ข้างหน้าลิงก์ New Issue

การซ่อนลิงก์ช่วยไม่ให้ USER สับสน แต่ requireAdmin() ในหน้า Admin ยังเป็นผู้ตรวจสิทธิ์จริง


Slide 18: สลับจาก Demo Policy เป็นสิทธิ์จริง

ตอนนี้ Auth, Role และ Policies ใหม่พร้อมแล้ว ขั้นสุดท้ายคือปิดสิทธิ์ชั่วคราวจาก Day 4

Policy ของคำสั่งเดียวกันทำงานแบบ OR หาก Demo Policy ที่ใช้ true ยังอยู่ ทุก Row จะยังผ่านได้ แม้เราสร้าง Policy ใหม่แล้ว

Demo Policy (true) OR Policy ใหม่ = ผ่าน

1. ลบ Demo Policies เดิม

drop policy if exists "demo_select_issues" on public.issues;
drop policy if exists "demo_insert_issues" on public.issues;
drop policy if exists "demo_update_issues" on public.issues;

if exists ช่วยให้รันคำสั่งซ้ำได้โดยไม่ Error หาก Policy นั้นถูกลบไปแล้ว

2. อนุญาตเฉพาะ Request ที่ Login แล้ว

แม้หน้า Policies จะเหลือเฉพาะกฎของ authenticated แล้ว ก็ยังต้องรันขั้นตอนนี้ เพราะหน้า Policies แสดงกฎ RLS แต่ไม่ได้ยืนยันว่า anon ถูกถอนสิทธิ์ระดับ Table แล้ว

revoke select, insert, update on public.issues from anon;
grant select, insert, update on public.issues to authenticated;
  • revoke ... from anon ถอนสิทธิ์ชั่วคราวที่เราเคยให้ไว้ใน Day 4
  • grant ... to authenticated ยืนยันว่าผู้ที่ Login แล้วส่งคำสั่งมาที่ Table ได้ คำสั่งนี้รันซ้ำได้
  • RLS จะตรวจต่อว่าผู้ใช้คนนั้นทำงานกับ Row ใดได้บ้าง

หลัง Slide นี้ระบบจะบังคับสิทธิ์จริง หากหน้าเว็บมี Error ให้ตรวจ Policies และ Code ตาม Slide ก่อนหน้าแทนการเปิด Demo Policy กลับมา


Slide 19: ทดสอบ USER และ ADMIN

Logout ก่อนสลับบัญชี เพื่อไม่ให้ Session เดิมค้างอยู่

ทดสอบ user@example.com

  1. Login แล้วสร้าง Issue ใหม่
  2. หน้า /issues ต้องเห็นเฉพาะ Issue ที่บัญชีนี้สร้าง
  3. ต้องไม่เห็น Column จัดการหรือลิงก์ Admin
  4. เปิด /admin/issues โดยตรง → ต้องกลับ /issues

ทดสอบ admin@example.com

  1. ต้องเห็นลิงก์ Admin
  2. หน้า /admin/issues ต้องเห็น Issue ทั้งหมด รวมข้อมูลเก่าที่ created_by เป็น null
  3. เปลี่ยน Status แล้วข้อมูลต้องอัปเดต

ทดสอบหลัง Logout

  1. เปิด /issues, /issues/[id] หรือ /issues/new → ต้องไป /login

ถ้าผลลัพธ์ไม่ตรง

อาการจุดที่ควรตรวจ
permission deniedGRANT และ Role ของ Request
new row violates row-level securityuser.id, created_by และ INSERT Policy
สร้างแล้วรายการว่างcreated_by ของ Row ล่าสุดและ SELECT Policy
อ่าน Role ไม่ได้ข้อมูลและ Policy ของ profiles
ADMIN เข้าไม่ได้Role ของ admin@example.com และ requireAdmin()

Slide 20: Authorization Flow พร้อมใช้งาน

Login → Session → Profile Role → Server Check → RLS → ข้อมูลที่ได้รับอนุญาต

Hour 4 จะทบทวน Security ทั้งระบบ ตรวจค่าที่เปิดเผยได้ และทดสอบ Flow บน Production


อ้างอิงสำหรับผู้สอน