Files
raisfast/docs/api-rule-engine.md
T

558 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Rule 引擎 — 完整架构文档
> 版本:v1(当前实现)+ PocketBase 对标分析
> 最后更新:2026-04-21
## 一、概述
API Rule 引擎为 CMS 动态路由提供**表达式级别的行级访问控制**。不同于简单的 `public/member/admin` 三档权限,Rule 引擎允许在 TOML schema 中声明过滤表达式,实现:
- **匿名用户只看到已发布文章**`filter`
- **登录用户额外看到自己的草稿**`filter_auth`
- **只能修改/删除自己的记录**(运行时求值)
- **管理员专属操作**(角色检查)
## 二、整体架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ 启动时(一次性) │
│ │
│ TOML Schema ──serde──▶ ContentTypeSchema │
│ │ │ │
│ │ ├─ cache_select_columns() │
│ │ └─ cache_rules() │
│ │ │ │
│ │ ▼ │
│ │ Rule::parse(filter_str) │
│ │ Rule::parse(filter_auth_str) │
│ │ │ │
│ │ ▼ │
│ │ CachedRulesAST
│ │ 存入 Arc<ContentTypeSchema> │
└───────┼─────────────────────────────────────────────────────────────┘
┌───────┼─────────────────────────────────────────────────────────────┐
│ │ 请求时(每次请求) │
│ │ │
│ ▼ │
│ HTTP Request ──▶ Handler │
│ │ │ │
│ │ ├─ 1. check_api_access(access) ← 粗粒度权限 │
│ │ │ │
│ │ └─ 2. build_rule_sql() ← 细粒度过滤 │
│ │ │ │
│ │ ├─ 读取 ct.cached_rules.list │
│ │ ├─ compile_rule_sql(filter, auth) │
│ │ └─ compile_rule_sql(filter_auth, auth) │
│ │ │ │
│ │ ▼ │
│ │ (sql_fragment, params) │
│ │ │ │
│ │ ▼ │
│ │ ContentQuery { │
│ │ rule_where: Some("status = ?1"), │
│ │ rule_params: vec!["published"], │
│ │ } │
│ │ │ │
│ │ ▼ │
│ │ Repository::find() │
│ │ └─ WHERE ... AND (status = 'published') │
│ │ │
│ └─ 3. do_update/do_delete → evaluate(rule, record, ctx) │
│ └─ 运行时对记录求值 → 允许/拒绝 │
└────────────────────────────────────────────────────────────────────┘
```
## 三、TOML Schema 定义
### 3.1 结构
每个 content type 的 `[api]` 部分为 5 个端点独立配置:
```toml
[api.list] # GET /cms/{plural}
[api.get] # GET /cms/{plural}/{id}
[api.create] # POST /cms/{plural}
[api.update] # PUT /cms/{plural}/{id}
[api.delete] # DELETE /cms/{plural}/{id}
```
每个端点有 3 个字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `access` | `"none"` / `"public"` / `"member"` / `"admin"` | 是(默认 `public` | 粗粒度访问级别 |
| `filter` | 字符串(规则表达式) | 否 | 对所有请求生效的数据过滤 |
| `filter_auth` | 字符串(规则表达式) | 否 | 已登录用户的额外过滤(与 filter 取 OR) |
### 3.2 示例
**博客文章 — 匿名只看已发布,登录可创建**
```toml
[api.list]
access = "public"
filter = 'status = "published"'
[api.get]
access = "public"
filter = 'status = "published"'
[api.create]
access = "member"
[api.update]
access = "member"
filter = 'author_id = @request.auth.id'
[api.delete]
access = "admin"
```
**电商订单 — 用户只能看自己的订单**
```toml
[api.list]
access = "member"
filter_auth = 'user_id = @request.auth.id'
[api.get]
access = "member"
filter_auth = 'user_id = @request.auth.id'
[api.create]
access = "member"
[api.update]
access = "member"
filter = 'user_id = @request.auth.id'
[api.delete]
access = "admin"
```
**完全禁止外部访问(仅内部/插件使用)**
```toml
[api.list]
access = "none"
[api.get]
access = "none"
[api.create]
access = "none"
[api.update]
access = "none"
[api.delete]
access = "none"
```
## 四、规则表达式语法
### 4.1 当前已实现(v1
#### 比较运算符
| 运算符 | 含义 | 示例 |
|--------|------|------|
| `=` | 等于 | `status = "published"` |
| `!=` | 不等于 | `status != "draft"` |
| `>` | 大于 | `price > 100` |
| `>=` | 大于等于 | `stock >= 1` |
| `<` | 小于 | `view_count < 1000` |
| `<=` | 小于等于 | `created_at <= "2026-01-01"` |
| `~` | LIKE 模糊匹配 | `title ~ "%rust%"` |
| `!~` | NOT LIKE | `title !~ "%spam%"` |
#### 逻辑组合
| 运算符 | 含义 | 示例 |
|--------|------|------|
| `&&` | 逻辑与 | `status = "published" && author_id = @request.auth.id` |
| `\|\|` | 逻辑或 | `status = "published" \|\| status = "draft"` |
| `()` | 优先级分组 | `(a = 1 \|\| b = 2) && c = 3` |
#### 特殊变量
| 变量 | 含义 | 示例 |
|------|------|------|
| `@request.auth.id` | 当前登录用户 ID | `author_id = @request.auth.id` |
| `@request.auth.role` | 当前登录用户角色 | `@request.auth.role = "admin"` |
#### 字面量
| 类型 | 示例 |
|------|------|
| 字符串 | `"published"``"admin"` |
| 数字 | `100``3.14` |
| 布尔 | `true``false` |
| 空值 | `null` |
### 4.2 filter 与 filter_auth 的组合逻辑
```
请求到达
├─ 未登录 → 只用 filter
│ filter 存在 → 编译为 SQL WHERE
│ filter 不存在 → 不过滤
└─ 已登录 → filter OR filter_auth
两者都有 → WHERE (filter_sql OR filter_auth_sql)
只有 filter → WHERE filter_sql
只有 filter_auth → WHERE filter_auth_sql
都没有 → 不过滤
```
### 4.3 已实现(Phase 1
| 功能 | 语法 | SQL 编译 | 运行时求值 | 状态 |
|------|------|----------|-----------|------|
| 请求体引用 | `@request.body.title` | 占位符(仅运行时) | ✅ 从 RuleContext.body 取值 | ✅ |
| 查询参数引用 | `@request.query.category` | 占位符(仅运行时) | ✅ 从 RuleContext.query_params 取值 | ✅ |
| 当前时间 | `@now` | `datetime('now')`(可配置) | ✅ ISO 8601 UTC | ✅ |
| 字段存在检查 | `title:isset` | `IS NOT NULL`(可配置) | ✅ `!is_null()` | ✅ |
| 长度检查 | `tags:length > 0` | `LENGTH(field)`(可配置) | ✅ String/Array/Object | ✅ |
### 4.4 未实现(对标 PocketBase
| 功能 | PocketBase 语法 | raisfast 语法 | 用途 | 优先级 |
|------|----------------|---------------|------|--------|
| 字段变更检查 | `title:changed` | `title:changed` | update 时判断字段是否修改 | P2 |
| 数组遍历 | `tags:each = "rust"` | `tags:each = "rust"` | 检查数组元素 | P2 |
| 多值 ANY 操作 | `status ?= "published\|draft"` | `status ?= "published\|draft"` | 字段匹配任意一个值 | P2 |
| 多值 NONE 操作 | `status ?!= "deleted"` | `status ?!= "deleted"` | 字段不匹配任何值 | P2 |
| 多值 LIKE | `title ?~ "%rust%\|%.js"` | `title ?~ "%rust%\|%.js"` | 模糊匹配任意一个模式 | P2 |
| 跨表引用 | `@collection.posts.author` | `@table.posts.author` | 关联查询 | P3 |
| 时间格式化 | `strftime("%Y", created_at)` | `strftime("%Y", created_at)` | 提取时间部分 | P3 |
| 地理距离 | `geoDistance(lat, lng, 40.7, -74.0)` | `geoDistance(lat, lng, 40.7, -74.0)` | LBS 过滤 | P3 |
## 五、核心代码结构
### 5.1 文件组织
```
src/content_type/
├── rule_engine.rs # 表达式引擎核心(Lexer + Parser + AST + SQL/Evaluate
├── schema.rs # ApiConfig + ApiEndpointConfig + CachedRules
├── handler.rs # build_rule_sql() + do_list/do_get/do_update/do_delete 集成
├── repository.rs # ContentQuery.rule_where/rule_params + find() 注入
└── ...
plugin-wit/plugin.wit # WIT 接口(含 filter/filter_auth 类型)
```
### 5.2 rule_engine.rs 内部结构
```
rule_engine.rs
├── RuleContext # 求值上下文(auth_user_id, auth_role, body, query_params
├── Lexer # 词法分析:字符串 → Vec<Token>
│ ├── Token::Identifier # 字段名、@request.auth.*
│ ├── Token::StringLit # "字符串"
│ ├── Token::NumberLit # 数字
│ ├── Token::BoolLit # true/false
│ ├── Token::NullLit # null
│ ├── Token::Eq/Neq/... # 比较运算符
│ ├── Token::And/Or # 逻辑运算符
│ └── Token::LParen/RParen
├── Parser # 语法分析:Vec<Token> → Expr (AST)
│ ├── parse_or() # || 优先级最低
│ ├── parse_and() # && 优先级中间
│ ├── parse_comparison() # 比较运算符优先级最高
│ └── parse_atom() # 操作数(字段名/字面量/特殊变量)
├── Expr (AST) # 抽象语法树
│ ├── Compare { left, op, right } # 比较表达式
│ ├── And(lhs, rhs) # 逻辑与
│ └── Or(lhs, rhs) # 逻辑或
├── Operand # 操作数
│ ├── Field(String) # 数据库字段
│ ├── AuthId # @request.auth.id
│ ├── AuthRole # @request.auth.role
│ ├── StringLit/NumberLit/... # 字面量
│ └── Null # null
├── Rule # 解析后的规则
│ ├── parse(source) → Rule # 入口:字符串 → AST
│ ├── to_sql(offset) → (sql, params) # 编译为 SQL
│ └── evaluate(record, ctx) → bool # 运行时求值
└── compile_rule_sql() # 带 auth 替换的 SQL 编译(公共接口)
```
### 5.3 schema.rs 相关类型
```rust
// 访问级别
enum ApiAccess { None, Public, Member, Admin }
// 单个端点配置
struct ApiEndpointConfig {
access: ApiAccess,
filter: Option<String>, // 原始表达式字符串
filter_auth: Option<String>, // 原始表达式字符串
}
// 5 个端点
struct ApiConfig {
list: ApiEndpointConfig,
get: ApiEndpointConfig,
create: ApiEndpointConfig,
update: ApiEndpointConfig,
delete: ApiEndpointConfig,
}
// 预解析缓存(启动时填充,不序列化)
struct CachedEndpointRules {
filter: Option<Rule>, // 已解析的 AST
filter_auth: Option<Rule>, // 已解析的 AST
}
struct CachedRules {
list: CachedEndpointRules,
get: CachedEndpointRules,
create: CachedEndpointRules,
update: CachedEndpointRules,
delete: CachedEndpointRules,
}
struct ContentTypeSchema {
// ...
api: ApiConfig,
cached_rules: Option<CachedRules>, // 注册时 cache_rules() 填充
}
```
### 5.4 handler.rs 集成点
```
请求到达
├─ check_api_access(endpoint.access) ← 粗粒度:None/Public/Member/Admin
├─ build_rule_sql(&endpoint, auth) ← 细粒度:编译规则为 SQL
│ ├─ filter 编译为 SQL(参数化)
│ ├─ filter_auth 编译为 SQL(注入 auth.id/role
│ └─ 组合:(filter_sql OR filter_auth_sql)
├─ [list/get] ContentQuery.rule_where 注入 → Repository::find()
│ → SQL WHERE ... AND (rule)
└─ [update/delete] 先 find_by_id → Rule::evaluate(record, ctx)
→ false → 403 Forbidden
```
### 5.5 repository.rs 查询注入
```rust
struct ContentQuery {
// ... 原有字段 ...
pub rule_where: Option<String>, // API Rule 编译的 SQL 片段
pub rule_params: Vec<String>, // 对应参数
}
// find() 中:
// 1. 构建 WHERE 子句(status、filters、tenant_id...
// 2. 追加 rule_whereAND (rule_sql)
// 3. 追加 rule_params 到参数列表
// 4. 绑定参数执行查询
```
## 六、性能设计
| 环节 | 策略 | 说明 |
|------|------|------|
| 规则解析 | **启动时一次** | `cache_rules()` 在 schema 注册时解析,请求时零解析开销 |
| SQL 编译 | **每次请求** | `compile_rule_sql()` 生成 WHERE 片段 + 参数列表,开销 < 1μs |
| 运行时求值 | **CUD 时** | `Rule::evaluate()` 遍历 AST 树,开销 < 1μs |
| 参数绑定 | **参数化** | 规则编译为 `?N` 占位符 + 参数列表,防 SQL 注入 |
| LIKE 实现 | **SQL LIKE** | 使用 SQL 原生 `LIKE`,非 Regex 编译 |
## 七、安全设计
| 威胁 | 防护 |
|------|------|
| SQL 注入 | 规则编译为参数化查询,用户输入不拼入 SQL |
| 越权访问 | `check_api_access` + rule evaluate 双重检查 |
| auth 伪造 | auth.id/role 从 JWT 解析,非客户端传入 |
| 规则语法错误 | `Rule::parse()` 返回 Errschema 加载时 log warn 并跳过该规则 |
| 未认证请求 | `compile_rule_sql` 对需要 auth 的规则返回 None → 拒绝请求 |
## 八、请求处理流程(完整示例)
### 场景:匿名用户 GET /cms/articles
```
1. Handler 收到请求
2. check_api_access(Public) → ✅ 通过
3. build_rule_sql(list, auth=None)
├─ filter = Rule('status = "published"')
├─ compile_rule_sql(rule, auth=None)
│ ├─ to_sql(0) → ("status = ?1", ["published"])
│ └─ 无 auth 替换 → 返回 ("status = ?1", ["published"])
└─ filter_auth 存在但 auth=None → 忽略
4. ContentQuery { rule_where: "status = ?1", rule_params: ["published"] }
5. Repository::find()
SQL: SELECT ... FROM articles WHERE ... AND (status = ?1)
Bind: ["published"]
6. 返回已发布文章列表
```
### 场景:作者 GET /cms/articles(已登录 user_id=abc
```
1. Handler 收到请求
2. check_api_access(Public) → ✅ 通过
3. build_rule_sql(list, auth=Some(abc, member))
├─ filter = Rule('status = "published"')
│ → compile → ("status = ?1", ["published"])
├─ filter_auth = Rule('author_id = @request.auth.id')
│ → compile with auth → ("author_id = 'abc'", [])
└─ 组合 → ("(status = ?1 OR author_id = 'abc')", ["published"])
4. ContentQuery { rule_where: "(status = ?1 OR author_id = 'abc')", ... }
5. SQL: WHERE ... AND ((status = 'published' OR author_id = 'abc'))
6. 返回已发布文章 + 作者 abc 的草稿
```
### 场景:非作者 PUT /cms/articles/123user_id=xyz, author_id=abc
```
1. Handler 收到请求
2. check_api_access(Member) → ✅ 通过(已登录)
3. do_update 中 evaluate:
├─ 先 find_by_id → record = { author_id: "abc", ... }
├─ rule = Rule('author_id = @request.auth.id')
├─ evaluate(record, ctx={ auth_user_id: "xyz" })
│ → "abc" == "xyz" → false
└─ 返回 403 Forbidden
```
## 九、与 PocketBase 对比
| 维度 | PocketBase | raisfast(当前) | raisfast(计划) |
|------|-----------|------------------|------------------|
| 配置格式 | listRule/viewRule/... 字符串 | `[api.list] access + filter + filter_auth` | 同 |
| 表达式解析 | `fexpr`(自研) | 自研 Lexer + Parser | 同 |
| SQL 编译 | filter → WHERE | Rule::to_sql() + RuleEngineConfig | 同 |
| 运行时求值 | Go 原生 | Rust serde_json + RuleEngineConfig | 同 |
| 环境变量配置 | ❌ | ✅ RuleEngineConfig14 项) | 同 |
| `@request.auth.*` | ✅ | ✅ | — |
| `@request.body.*` | ✅ | ✅ | — |
| `@request.query.*` | ✅ | ✅ | — |
| `@now` | ✅ | ✅ | — |
| `:isset` | ✅ | ✅ | — |
| `:length` | ✅ | ✅ | — |
| `:changed` | ✅ | ❌ | Phase 2 |
| `:each` | ✅ | ❌ | Phase 2 |
| `?= ?!= ?~` | ✅ | ❌ | Phase 2 |
| `@table.*`(跨表) | ✅ | ❌ | Phase 3 |
| `geoDistance()` | ✅ | ❌ | Phase 3 |
| `strftime()` | ✅ | ❌ | Phase 3 |
| **覆盖率** | **100%** | **~30%** | **~85%**Phase 1-3 后) |
## 十、扩展路线图
### Phase 11 天,覆盖 60% 场景)
```
新增 Operand 变体:
- RequestBody(String) ← @request.body.field_name
- RequestQuery(String) ← @request.query.param_name
- Now ← @now
新增 AST 节点:
- IsSet(Operand) ← field:isset
- Length(Operand) ← field:length
新增 SQL 编译:
- Now → datetime('now')
- IsSet → field IS NOT NULL
- Length → LENGTH(field)
```
### Phase 21 天,覆盖 75% 场景)
```
新增 CmpOp
- AnyEq / AnyNeq / AnyLike ← ?= ?!= ?~
新增后缀解析:
- :changed ← update 前后对比
新增 AST 节点:
- Each(Operand, Expr) ← field:each = "value"
修改 evaluate 签名:
- 改为 async(为 @collection 准备)
```
### Phase 32 天,覆盖 85% 场景)
```
新增跨表查询:
- @table.posts.author ← async DB 查询(前缀可配置 RULE_PREFIX_CROSS_TABLE
新增函数:
- strftime(fmt, field) ← 时间格式化
- geoDistance(lat, lng, lat2, lng2) ← 地理距离
```
## 十一、环境变量配置
所有规则引擎中的硬编码值均可通过环境变量覆盖,便于适配不同数据库后端(SQLite / PostgreSQL / MySQL)。
`AppConfig.rule_engine: RuleEngineConfig` 中定义,通过 `.env` 或环境变量设置:
### 表达式前缀
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `RULE_PREFIX_AUTH_ID` | `@request.auth.id` | 认证用户 ID 前缀 |
| `RULE_PREFIX_AUTH_ROLE` | `@request.auth.role` | 认证用户角色前缀 |
| `RULE_PREFIX_REQUEST_BODY` | `@request.body.` | 请求体字段前缀 |
| `RULE_PREFIX_REQUEST_QUERY` | `@request.query.` | URL 查询参数前缀 |
| `RULE_PREFIX_NOW` | `@now` | 当前时间前缀 |
| `RULE_PREFIX_CROSS_TABLE` | `@table.` | 跨表引用前缀(Phase 3 |
### SQL 编译
| 环境变量 | 默认值 | PostgreSQL 替代 | 说明 |
|---------|--------|---------------|------|
| `RULE_SQL_NOW_FN` | `datetime('now')` | `NOW()` | @now 编译为的 SQL 函数 |
| `RULE_SQL_ISSET_OP` | `IS NOT NULL` | `IS NOT NULL` | :isset 编译为的操作符 |
| `RULE_SQL_LENGTH_FN` | `LENGTH` | `CHAR_LENGTH` | :length 编译为的函数名 |
### LIKE 通配符
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `RULE_SQL_LIKE_WILDCARD` | `%` | SQL LIKE 通配符 |
| `RULE_SQL_LIKE_SINGLE_CHAR` | `_` | SQL LIKE 单字符通配符 |
| `RULE_REGEX_LIKE_WILDCARD` | `.*` | LIKE 通配符对应的正则 |
| `RULE_REGEX_LIKE_SINGLE_CHAR` | `.` | LIKE 单字符对应的正则 |
### 缓存
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `CMS_CACHE_TTL` | `30` | CMS 列表/详情缓存 TTL(秒) |
### 配置示例
**PostgreSQL 适配:**
```env
RULE_SQL_NOW_FN=NOW()
RULE_SQL_LENGTH_FN=CHAR_LENGTH
CMS_CACHE_TTL=60
```