初探supabase: RLS、trigger、edge function

177 阅读5分钟

supabase是什么?

一个BaaS,在postgres上增加api层,为每个表生成api。并且支持 edge function(serverless函数调用),storage (对象存储),认证等

管理项目

创建一个Project,填写基本信息,包括数据库密码,选择Region

image.png

创建好之后,到首页可以看到一个URL和API KEY,前端将通过他来调用 supabase的提供的data api对数据库进行增删改查。

下面的小字说,这个key是可以被公开的,那么supabase是如何确保数据库操作的安全呢?答案是通过RLS实现的行级别的鉴权,这个后面会说到。

image.png

认证

supabase提供了开箱即用的认证功能,可以不写一行代码实现用户注册、登录等。

到 API docs -- User Managerment可以看到如何用去注册一个用户

image.png

我们命令行复制 并执行:

image.png

发现api成功返回了,然后到Table Editor中auth.users中可以看到,多了一条用户记录

image.png

这里schema有多种,其中auth是supabase直接管理的,用来实现认证,用户不能进行修改

用户创建的表在public下,创建完之后 supabase 会自动生成增删改查的api

建表

从上一部分可以看到,auth.users 并不存在 用户名称、简介等业务信息,就需要我们自己在public下创建一个users表,然后使用外键建立关联

image.png

设置外键,将public.users.uid 指向 auth.users.id ,并且设置当 auth.users记录被删除时,同步删除业务表的记录

image.png

trigger function

现在发现了个问题,当用户注册的时候,auth.users 会多一条记录,但是public.users却没有,难道还需要我们登录之后手动调用insert创建吗?

其实不用,我们可以使用supabase的trigger function,auth.users改变时触发,执行sql给public.users插入一条记录

到 Database -> function, 在public创建一个function,其实就是执行了一段sql,其中 new 代表auth.users中的一条记录,raw_user_meta_data 则是调用接口时传入的业务对象,这个现在看起来有点懵逼,后面会说到

-- 创建函数
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.users (uid, username)
  VALUES (
    NEW.id,
    NEW.raw_user_meta_data->>'username',
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

有了执行逻辑,还要定义什么时候触发,到database -> triggers, 在public下新增一个trigger,指定到auth.users表插入的时候执行对应的trigger:

-- 创建触发器
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

image.png

然后删除刚刚的数据,

delete from auth.users where id = '06ca9840-a63f-447e-96af-e7f3ff768342'

重新注册一下,注意这里data对象就是上面函数中的raw_user_meta_data:

image.png

再查看一下业务表 public.users,多了一条记录

image.png

RLS: Row Level Security

如果想对某些行进行鉴权,比如

  1. 用户只能修改自己的信息
  2. 任何人都可以创建post
  3. 任何人只能查看自己的post

就需要用到RLS,通过创建polices来控制,官方的例子很具体了,这里解释一下

create policy "Public profiles are viewable only by authenticated users"
on profiles for select
to authenticated
using ( true );

表示创建了一个规则,对于profiles的select操作,对登录用户进行限制,限制为true,就是任何登录用户都可以查看profiles

create policy "Users can update their own profile."
on profiles for update
to authenticated -- the Postgres Role (recommended)
using ( (select auth.uid()) = user_id ) -- checks if the existing row complies with the policy expression
with check ( (select auth.uid()) = user_id ); -- checks if the new row complies with the policy expression

这里 auth.uid() 是从读取请求中的jwt解析得到的人当前登录用户的id,user_id是表中的列

这个polices限制了,对于profile的更新操作,只有登录用户,可以查看到自己的(using),并且修改后id不能变(with check),没有with check的话,用户可以把自己的profile中的uid改成别人的

这里区分一下using和with check的区别:

逻辑含义
USING能不能选中这行(决定可操作范围)
WITH CHECK改完后是否合法(防止越权写入)

使用edge function实现限流

创建个业务表 image.png

再塞一条数据

image.png

由于没有设置ploices现在通过 api是请求不到的,增加一个polices,任何人都可以读取posts

image.png postman中连续请求多次,都可以返回:

image.png

此时是没有限制速率的,supabase官方文档提供了通过redis实现限流的方案:supabase.com/docs/guides…

可以使用upstash serverless服务,创建好一个数据库可以到详情页面

image.png

可以在网页写限流的逻辑,但由于很多端点都需要用到限流,为了共用模块,我们使用supabase cli在本地进行开发,然后部署到云

在项目目录执行

supabase init
supabase login
supabase projects list # 查看项目列表
supabase link --project-ref <your-project-ref> # 绑定项目

目录结构整理如下:

➜  supabase_juejin tree -L 4                                          
.
└── supabase
    ├── config.toml
    └── functions
        ├── _shared
        │   ├── auth.ts
        │   └── ratelimit.ts
        └── get-posts
            ├── deno.json
            ├── deno.lock
            └── index.ts

由于auth和ratelimit可以各个函数都通用,我们把它放到_shared目录下,编写auth.ts如下

import { decode } from "https://deno.land/x/djwt@v2.8/mod.ts";



export function getToken(req: Request) {
  const h = req.headers.get("Authorization") || "";
  return h.startsWith("Bearer ") ? h.slice(7) : "";
}

export function getUserId(token: string): string {
  try {
    const decoded = decode(token) as [any, { sub?: string }, any];
    return decoded[1].sub ?? "";
  } catch {
    return "";
  }
}
export function getIp(req: Request) {
  return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
      || req.headers.get("cf-connecting-ip")
      || "unknown";
}

ratelimit.ts 如下:

import { Redis } from 'https://deno.land/x/upstash_redis@v1.19.3/mod.ts'
import { Ratelimit } from 'https://cdn.skypack.dev/@upstash/ratelimit@0.4.4'
import { getToken, getUserId, getIp } from './auth.ts';

// 限流配置接口
export interface RateLimitConfig {
  requests: number;
  window: string;
  analytics?: boolean;
}

// 限流结果接口
export interface RateLimitResult {
  success: boolean;
  limit: number;
  remaining: number;
  reset: number;
  identifier: string;
}

// 创建Redis连接
function createRedisClient(): Redis {
  return new Redis({
    url: Deno.env.get('UPSTASH_REDIS_REST_URL')!,
    token: Deno.env.get('UPSTASH_REDIS_REST_TOKEN')!,
  });
}

// 创建限流器
function createRateLimiter(config: RateLimitConfig) {
  const redis = createRedisClient();
  return new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(config.requests, config.window),
    analytics: config.analytics ?? true,
  });
}

// 获取用户标识符
export function getIdentifier(req: Request): string {
  const userId = getUserId(getToken(req));
  const ip = getIp(req);
  return userId || `ip:${ip}`;
}

// 检查限流
export async function checkRateLimit(
  req: Request, 
  config: RateLimitConfig = { requests: 10, window: '1 m' }
): Promise<RateLimitResult> {
  const identifier = getIdentifier(req);
  const ratelimit = createRateLimiter(config);
  const result = await ratelimit.limit(identifier);
  
  return {
    success: result.success,
    limit: result.limit,
    remaining: result.remaining,
    reset: result.reset,
    identifier
  };
}

// 创建限流响应
export function createRateLimitResponse(result: RateLimitResult): Response {
  const headers = {
    'X-RateLimit-Limit': result.limit.toString(),
    'X-RateLimit-Remaining': result.remaining.toString(),
    'X-RateLimit-Reset': result.reset.toString()
  };

  if (!result.success) {
    return new Response(JSON.stringify({ 
      error: 'Rate limit exceeded',
      limit: result.limit,
      remaining: result.remaining,
      reset: new Date(result.reset)
    }), { 
      status: 429,
      headers
    });
  }

  return new Response(JSON.stringify({ 
    success: result.success,
    limit: result.limit,
    remaining: result.remaining,
    reset: new Date(result.reset),
    identifier: result.identifier
  }), { 
    status: 200,
    headers
  });
}

// 限流中间件 - 可以直接在Edge Function中使用
export async function withRateLimit(
  req: Request,
  handler: (req: Request) => Promise<Response>,
  config: RateLimitConfig = { requests: 10, window: '1 m' }
): Promise<Response> {
  try {
    const rateLimitResult = await checkRateLimit(req, config);
    
    if (!rateLimitResult.success) {
      return createRateLimitResponse(rateLimitResult);
    }
    
    // 执行实际的处理器
    const response = await handler(req);
    
    // 添加限流头部到响应中
    const headers = new Headers(response.headers);
    headers.set('X-RateLimit-Limit', rateLimitResult.limit.toString());
    headers.set('X-RateLimit-Remaining', rateLimitResult.remaining.toString());
    headers.set('X-RateLimit-Reset', rateLimitResult.reset.toString());
    
    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers
    });
  } catch (error) {
    return new Response(JSON.stringify({ 
      error: error instanceof Error ? error.message : 'Unknown error' 
    }), {
      status: 500,
    });
  }
}


get-post中可以先不调用接口,写点假数据 能调通就行:

// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.

// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts"
import { getUserId, getIp, getToken } from "@shared/auth.ts";
import { withRateLimit } from "@shared/ratelimit.ts";

console.log("Hello from Functions!")

// 实际的业务逻辑处理器
function getPostsHandler(req: Request): Response {
  const token = getToken(req);
  const userId = getUserId(token);
  
  if (!userId) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
  }
  
  const ip = getIp(req);
  const identifier = `${userId}-${ip}`;
  
  const data = {
    message: `Hello ${identifier}!`,
    posts: [
      { id: 1, title: "Post 1", content: "This is post 1" },
      { id: 2, title: "Post 2", content: "This is post 2" }
    ]
  };

  return new Response(
    JSON.stringify(data),
    { headers: { "Content-Type": "application/json" } }
  );
}

// 使用限流中间件包装处理器
Deno.serve(async (req) => {
  return await withRateLimit(
    req,
    (req) => Promise.resolve(getPostsHandler(req)),
    { requests: 5, window: '1 m' } // 每分钟最多5次请求
  );
});


然后部署到云端:

supabase functions deploy

在命令行连续请求多次,发现可以触发限流的提示:

image.png

待补充