access-control-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAccess Control Patterns
访问控制模式
Prevent users from accessing data or actions they are not allowed to access. Broken Access Control is OWASP A01 because it is the most common serious web vulnerability: user A can read user B's invoice, change another tenant's settings, or call an admin endpoint by guessing an ID.
防止用户访问其无权访问的数据或操作。访问控制失效是OWASP A01级别的漏洞,因为它是最常见的严重Web漏洞:用户A可以读取用户B的发票、修改其他租户的设置,或通过猜测ID调用管理端点。
The Rule
规则
Authentication proves who the user is. Authorization proves what the user can do. You need both on every protected operation.
身份验证用于证明用户是谁。授权用于证明用户可以做什么。每个受保护的操作都需要同时具备两者。
Core Principles
核心原则
- Deny by default -- no route is public unless explicitly marked public
- Check authorization server-side -- never trust hidden buttons or client checks
- Scope every query by user or tenant -- never fetch by raw ID alone
- Use centralized policies -- avoid scattered checks
if (user.role === 'admin') - Separate roles from permissions -- roles are bundles, permissions are actions
- Audit sensitive decisions -- log denied and privileged actions
- 默认拒绝——除非明确标记为公开,否则所有路由均为私有
- 在服务端校验授权——绝不信任隐藏按钮或客户端校验
- 所有查询按用户或租户限定范围——绝不单独通过原始ID获取数据
- 使用集中式策略——避免零散的校验
if (user.role === 'admin') - 角色与权限分离——角色是权限集合,权限是具体操作
- 审计敏感决策——记录被拒绝的操作和特权操作
Vulnerable Patterns
易受攻击的模式
IDOR: Insecure Direct Object Reference
IDOR:不安全直接对象引用
BAD:
typescript
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice); // Any logged-in user can read any invoice by guessing ID
});GOOD:
typescript
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
tenantId: req.user.tenantId,
},
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});Return , not , when resource existence should not be revealed.
404403BAD:
typescript
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice); // Any logged-in user can read any invoice by guessing ID
});GOOD:
typescript
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
tenantId: req.user.tenantId,
},
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});当不应暴露资源存在性时,返回而非。
404403Enterprise Authorization Model
企业级授权模型
Use this decision order for every protected operation:
- Is the user authenticated?
- Is the account active and not suspended?
- Is the user inside the tenant/workspace being accessed?
- Does the user have the required permission?
- Does the resource belong to the user's tenant?
- Are there object-level conditions? (owner, assigned user, approval state)
- Should the action be audit logged?
对每个受保护的操作,按以下顺序进行决策:
- 用户是否已通过身份验证?
- 账户是否处于活跃状态且未被暂停?
- 用户是否在被访问的租户/工作区内?
- 用户是否具备所需权限?
- 资源是否属于用户的租户?
- 是否存在对象级条件?(所有者、分配的用户、审批状态)
- 操作是否需要审计日志?
RBAC Pattern
RBAC模式
typescript
type Role = 'owner' | 'admin' | 'member' | 'viewer';
type Permission =
| 'invoice:read'
| 'invoice:create'
| 'invoice:update'
| 'invoice:delete'
| 'user:invite'
| 'billing:manage';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
owner: ['invoice:read', 'invoice:create', 'invoice:update', 'invoice:delete', 'user:invite', 'billing:manage'],
admin: ['invoice:read', 'invoice:create', 'invoice:update', 'user:invite'],
member: ['invoice:read', 'invoice:create'],
viewer: ['invoice:read'],
};
function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}typescript
type Role = 'owner' | 'admin' | 'member' | 'viewer';
type Permission =
| 'invoice:read'
| 'invoice:create'
| 'invoice:update'
| 'invoice:delete'
| 'user:invite'
| 'billing:manage';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
owner: ['invoice:read', 'invoice:create', 'invoice:update', 'invoice:delete', 'user:invite', 'billing:manage'],
admin: ['invoice:read', 'invoice:create', 'invoice:update', 'user:invite'],
member: ['invoice:read', 'invoice:create'],
viewer: ['invoice:read'],
};
function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}ABAC Pattern
ABAC模式
Attribute-Based Access Control adds object-level conditions.
typescript
interface AuthContext {
userId: string;
tenantId: string;
role: Role;
permissions: Permission[];
}
interface Invoice {
id: string;
tenantId: string;
ownerId: string;
status: 'draft' | 'sent' | 'paid';
}
function canUpdateInvoice(user: AuthContext, invoice: Invoice): boolean {
if (invoice.tenantId !== user.tenantId) return false;
if (!hasPermission(user.role, 'invoice:update')) return false;
if (invoice.status === 'paid') return false;
if (user.role === 'member' && invoice.ownerId !== user.userId) return false;
return true;
}基于属性的访问控制(ABAC)增加了对象级条件。
typescript
interface AuthContext {
userId: string;
tenantId: string;
role: Role;
permissions: Permission[];
}
interface Invoice {
id: string;
tenantId: string;
ownerId: string;
status: 'draft' | 'sent' | 'paid';
}
function canUpdateInvoice(user: AuthContext, invoice: Invoice): boolean {
if (invoice.tenantId !== user.tenantId) return false;
if (!hasPermission(user.role, 'invoice:update')) return false;
if (invoice.status === 'paid') return false;
if (user.role === 'member' && invoice.ownerId !== user.userId) return false;
return true;
}Node.js / Express Middleware
Node.js / Express中间件
typescript
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
function requirePermission(permission: Permission) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!hasPermission(req.user.role, permission)) {
auditLog({
event: 'access.denied',
userId: req.user.id,
tenantId: req.user.tenantId,
permission,
path: req.path,
});
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.patch('/api/invoices/:id', requirePermission('invoice:update'), async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, tenantId: req.user.tenantId },
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
if (!canUpdateInvoice(req.user, invoice)) return res.status(403).json({ error: 'Forbidden' });
const updated = await db.invoice.update({
where: { id: invoice.id },
data: req.body,
});
res.json(updated);
});typescript
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
function requirePermission(permission: Permission) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!hasPermission(req.user.role, permission)) {
auditLog({
event: 'access.denied',
userId: req.user.id,
tenantId: req.user.tenantId,
permission,
path: req.path,
});
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.patch('/api/invoices/:id', requirePermission('invoice:update'), async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, tenantId: req.user.tenantId },
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
if (!canUpdateInvoice(req.user, invoice)) return res.status(403).json({ error: 'Forbidden' });
const updated = await db.invoice.update({
where: { id: invoice.id },
data: req.body,
});
res.json(updated);
});Cloudflare Workers Pattern
Cloudflare Workers模式
typescript
interface AuthContext {
userId: string;
tenantId: string;
role: Role;
}
async function requireAuth(request: Request, env: Env): Promise<AuthContext> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) throw new HttpError(401, 'Authentication required');
const session = await verifySession(token, env);
if (!session) throw new HttpError(401, 'Invalid session');
return session;
}
function requirePermission(auth: AuthContext, permission: Permission): void {
if (!hasPermission(auth.role, permission)) {
throw new HttpError(403, 'Forbidden');
}
}
async function getTenantScopedInvoice(db: D1Database, auth: AuthContext, invoiceId: string) {
return db.prepare(
'SELECT * FROM invoices WHERE id = ? AND tenant_id = ?'
).bind(invoiceId, auth.tenantId).first();
}typescript
interface AuthContext {
userId: string;
tenantId: string;
role: Role;
}
async function requireAuth(request: Request, env: Env): Promise<AuthContext> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) throw new HttpError(401, 'Authentication required');
const session = await verifySession(token, env);
if (!session) throw new HttpError(401, 'Invalid session');
return session;
}
function requirePermission(auth: AuthContext, permission: Permission): void {
if (!hasPermission(auth.role, permission)) {
throw new HttpError(403, 'Forbidden');
}
}
async function getTenantScopedInvoice(db: D1Database, auth: AuthContext, invoiceId: string) {
return db.prepare(
'SELECT * FROM invoices WHERE id = ? AND tenant_id = ?'
).bind(invoiceId, auth.tenantId).first();
}Laravel Policy Pattern
Laravel策略模式
php
// app/Policies/InvoicePolicy.php
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->hasPermission('invoice:read');
}
public function update(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->hasPermission('invoice:update')
&& $invoice->status !== 'paid';
}
}
// Controller
public function update(Request $request, Invoice $invoice)
{
$this->authorize('update', $invoice);
$invoice->update($request->validated());
return response()->json($invoice);
}Always add tenant scoping to route model binding or global scopes:
php
protected static function booted()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check()) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
}php
// app/Policies/InvoicePolicy.php
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->hasPermission('invoice:read');
}
public function update(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->hasPermission('invoice:update')
&& $invoice->status !== 'paid';
}
}
// Controller
public function update(Request $request, Invoice $invoice)
{
$this->authorize('update', $invoice);
$invoice->update($request->validated());
return response()->json($invoice);
}始终为路由模型绑定或全局作用域添加租户范围限定:
php
protected static function booted()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check()) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
}Python FastAPI Pattern
Python FastAPI模式
python
from fastapi import Depends, HTTPException
def require_permission(permission: str):
def dependency(user: User = Depends(current_user)):
if permission not in user.permissions:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return dependency
@app.patch("/invoices/{invoice_id}")
async def update_invoice(
invoice_id: str,
payload: InvoiceUpdate,
user: User = Depends(require_permission("invoice:update")),
):
invoice = await db.fetch_one(
"SELECT * FROM invoices WHERE id = :id AND tenant_id = :tenant_id",
{"id": invoice_id, "tenant_id": user.tenant_id},
)
if not invoice:
raise HTTPException(status_code=404, detail="Not found")
if invoice["status"] == "paid":
raise HTTPException(status_code=403, detail="Forbidden")
return await update_invoice_record(invoice_id, payload)python
from fastapi import Depends, HTTPException
def require_permission(permission: str):
def dependency(user: User = Depends(current_user)):
if permission not in user.permissions:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return dependency
@app.patch("/invoices/{invoice_id}")
async def update_invoice(
invoice_id: str,
payload: InvoiceUpdate,
user: User = Depends(require_permission("invoice:update")),
):
invoice = await db.fetch_one(
"SELECT * FROM invoices WHERE id = :id AND tenant_id = :tenant_id",
{"id": invoice_id, "tenant_id": user.tenant_id},
)
if not invoice:
raise HTTPException(status_code=404, detail="Not found")
if invoice["status"] == "paid":
raise HTTPException(status_code=403, detail="Forbidden")
return await update_invoice_record(invoice_id, payload)Database Row-Level Security
数据库行级安全
For PostgreSQL, enforce tenant isolation at the database layer too.
sql
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Set this per request/transaction
SELECT set_config('app.current_tenant_id', 'tenant-uuid-here', true);RLS does not replace application authorization, but it reduces blast radius when developers forget a tenant filter.
对于PostgreSQL,也在数据库层强制租户隔离。
sql
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Set this per request/transaction
SELECT set_config('app.current_tenant_id', 'tenant-uuid-here', true);RLS不能替代应用层授权,但可以在开发者忘记添加租户过滤器时减小影响范围。
Admin Route Protection
管理路由保护
Admin routes require stronger controls:
- Separate permissions from user roles
admin:* - Require MFA for admin actions
- Require recent authentication for sensitive changes
- Audit log all admin reads and writes
- Never rely on frontend route hiding
- Block admin access from unknown IPs where possible
typescript
function requireAdminAction(action: Permission) {
return [
requireAuth,
requirePermission(action),
requireRecentMfa({ maxAgeMinutes: 15 }),
auditAdminAction(action),
];
}管理路由需要更强的控制:
- 将权限与用户角色分离
admin:* - 管理操作需要多因素认证(MFA)
- 敏感变更需要近期的身份验证
- 审计记录所有管理读写操作
- 绝不依赖前端路由隐藏
- 尽可能阻止未知IP的管理访问
typescript
function requireAdminAction(action: Permission) {
return [
requireAuth,
requirePermission(action),
requireRecentMfa({ maxAgeMinutes: 15 }),
auditAdminAction(action),
];
}Access Control Audit Checklist
访问控制审计清单
- Every protected route has authentication middleware
- Every sensitive action has permission checks
- Every resource query is scoped by or
tenantIduserId - Raw ID lookups are not used for tenant-owned resources
- Users cannot access another tenant's data by changing URL IDs
- Frontend checks are duplicated server-side
- Admin routes require explicit admin permissions
- Admin actions require MFA or recent re-authentication
- Denied access attempts are audit logged
- Resource existence is not leaked when unauthorized
- Roles and permissions are centrally defined
- Database RLS or equivalent guardrails exist for multi-tenant apps
- Tests cover cross-tenant access attempts
- 每个受保护路由都有身份验证中间件
- 每个敏感操作都有权限校验
- 每个资源查询都按或
tenantId限定范围userId - 租户拥有的资源不使用原始ID查询
- 用户无法通过修改URL ID访问其他租户的数据
- 前端校验在服务端重复实现
- 管理路由需要明确的管理权限
- 管理操作需要MFA或近期重新认证
- 被拒绝的访问尝试已记录审计日志
- 未授权时不泄露资源存在性
- 角色和权限集中定义
- 多租户应用存在数据库RLS或等效防护机制
- 测试覆盖跨租户访问尝试
Required Tests
必选测试
Every multi-tenant project must include these tests:
typescript
it('prevents user from reading another tenant invoice', async () => {
const tenantAInvoice = await createInvoice({ tenantId: 'tenant-a' });
const tenantBUser = await createUser({ tenantId: 'tenant-b' });
const response = await request(app)
.get(`/api/invoices/${tenantAInvoice.id}`)
.set('Authorization', `Bearer ${tokenFor(tenantBUser)}`);
expect(response.status).toBe(404);
});每个多租户项目都必须包含以下测试:
typescript
it('prevents user from reading another tenant invoice', async () => {
const tenantAInvoice = await createInvoice({ tenantId: 'tenant-a' });
const tenantBUser = await createUser({ tenantId: 'tenant-b' });
const response = await request(app)
.get(`/api/invoices/${tenantAInvoice.id}`)
.set('Authorization', `Bearer ${tokenFor(tenantBUser)}`);
expect(response.status).toBe(404);
});PDPA 2024 Consequence
PDPA 2024合规后果
Broken access control is the fastest way to create a reportable personal data breach. If one customer can access another customer's personal data, the developer failed to implement basic authorization and tenant isolation. Under PDPA 2024 Seksyen 5(1) and 5(2), this can trigger up to RM1,000,000 fine or 3 years imprisonment.
访问控制失效是导致需上报的个人数据泄露的最快途径。如果一个客户可以访问另一个客户的个人数据,说明开发者未实现基本的授权和租户隔离。根据PDPA 2024第5(1)和5(2)条,这可能触发最高100万马来西亚林吉特的罚款或3年监禁。