本文目录导读:

在PHP中控制数据权限(Data-Level Permissions),通常比功能权限(如“能否访问用户管理页面”)更复杂,核心原则是:根据当前用户(或用户角色、所属组织),在查询数据库时动态追加 WHERE 条件,限制其只能看到或操作被授权的数据。
主要有以下几种常见的实现模式:
基于用户ID的权限(最基础)
用户只能看到自己的数据。
- 场景:用户中心,用户只能查看和编辑自己的资料。
- 实现:在SQL查询中强制加入
WHERE user_id = ?
<?php
$currentUserId = $_SESSION['user_id']; // 从登录状态获取
// 查询用户自己的订单
$stmt = $pdo->prepare("SELECT * FROM orders WHERE user_id = ?");
$stmt->execute([$currentUserId]);
// 更新时也需校验
$stmt = $pdo->prepare("UPDATE users SET nickname = ? WHERE id = ?");
$stmt->execute([$newNickname, $currentUserId]); // 注意:此处已经限制了只能改自己的
关键点:$currentUserId 不能被用户伪造,必须来自后端 Session/Token。
基于角色或部门的权限(RBAC扩展)
不同角色可以看到不同范围的同类型数据。 销售只能看自己的客户,销售经理可以看全部门的客户。
- 场景:CRM系统。
- 实现:将权限判断逻辑封装成“数据范围过滤器”。
<?php
function getDataPermissionSQL($pdo, $userId, $tableAlias = 'c') {
// 1. 获取用户的角色和部门信息
$user = getUserById($pdo, $userId);
$role = $user['role']; // 如:'saler', 'manager', 'admin'
$departmentId = $user['department_id'];
// 2. 根据角色返回不同的SQL片段
switch ($role) {
case 'admin':
// 管理员看全部
return "1=1"; // 无限制
case 'manager':
// 经理看自己部门的
return "{$tableAlias}.department_id = " . intval($departmentId);
case 'saler':
// 销售看自己的
return "{$tableAlias}.user_id = " . intval($userId);
default:
return "1=0"; // 不允许查看
}
}
// 在查询时使用
$permissionFilter = getDataPermissionSQL($pdo, $currentUserId, 'o');
$stmt = $pdo->prepare("SELECT * FROM orders o WHERE {$permissionFilter} AND o.status = ?");
$stmt->execute(['active']);
注意:直接拼接SQL有SQL注入风险,上面的例子中,intval() 确保了传入的是整数,如果是字符串必须使用参数绑定。更好的方式是使用ORM的查询构造器来构建条件。
基于组织树(层级结构)的权限(较复杂)
大型企业系统中,数据权限通常是树状结构,集团CEO可以看全国数据,省总经理看本省,城市经理看本市。
- 场景:SaaS平台,多租户中的内部层级权限。
- 实现:在数据结构中包含
org_path字段,使用LIKE或字符串匹配 + 索引。
-- 用户表结构
-- id, name, org_id
-- 组织表结构
-- id, name, parent_id, path (如: "/1/10/100/")
-- 权限判断
-- 假如当前用户所属的org_path是 /1/10/100/
-- 那么他可以查看所有 org_path LIKE '/1/10/100/%' 的数据
-- 或者查看所有 org_id IN (100, 101, 1001...) (需要先递归查出子组织)
// PHP逻辑
function getChildOrgIds($pdo, $orgId) {
// 使用递归查询(MySQL 8.0+ 用CTE)或者循环查询
$children = [$orgId];
$query = "SELECT id FROM organizations WHERE parent_id = ?";
// ... 递归逻辑
return $children;
}
$accessibleOrgIds = getChildOrgIds($pdo, $currentUserOrgId);
$placeholders = implode(',', array_fill(0, count($accessibleOrgIds), '?'));
$stmt = $pdo->prepare("SELECT * FROM reports WHERE org_id IN ($placeholders)");
$stmt->execute($accessibleOrgIds);
基于数据归属(数据本身字段)的权限
数据本身有一个 owner_id 或 created_by,结合上面的角色判断。
-- 例如项目管理系统,一个问题(Issue)有 创建者、负责人、关注者。
-- 权限规则:创建者、负责人、管理员可以编辑。
-- PHP 查询时
$currentUserId = $_SESSION['user_id'];
// 获取用户角色(是否为管理员)
$isAdmin = checkUserRole($currentUserId, 'admin');
if ($isAdmin) {
$sql = "SELECT * FROM issues WHERE project_id = ?"; // 管理员看全部
} else {
// 普通用户只能看与自己相关的
$sql = "SELECT * FROM issues WHERE project_id = ? AND (owner_id = ? OR assignee_id = ?)";
}
$stmt = $pdo->prepare($sql);
if ($isAdmin) {
$stmt->execute([$projectId]);
} else {
$stmt->execute([$projectId, $currentUserId, $currentUserId]);
}
更优雅的解决方案:使用中间件与设计模式
为了不让权限逻辑散落得到处都是,可以采用 AOP(面向切面编程) 思想。
推荐的工具和模式:
- 使用ORM的全局作用域(Global Scope):
- Laravel 的
Global Scopes,在模型中定义apply方法,自动为所有查询追加where user_id = ?,这是非常爽的一种方式,能彻底杜绝忘记加权限过滤的情况。
- Laravel 的
- 使用Repository模式:
- 创建一个
OrderRepository,所有对订单的查询都必须通过这个Repository,在Repository内部统一处理数据权限过滤。
- 创建一个
- 使用中间件(Middleware):
对于API请求,在进入Controller之前,中间件解析出用户信息并注入到请求对象中,后续的Service层可以使用该信息。
总结与最佳实践
- 永远信任后端,不信任前端:所有的
user_id、org_id必须从Session/Token中读取,不能从请求参数中取。 - 统一过滤入口:尽量在模型层(ORM Scope)或数据访问层(Repository)解决,不要在Controller里每次手动写。
- 性能考虑:
- 避免在十万级数据表上使用
LIKE '/path/%'进行权限过滤(会导致索引失效)。 - 使用组织树权限时,可以先获取用户有权限的
org_id列表,然后用IN查询,但注意IN中的ID数量,太多的话可以分批或改用临时表关联。 - 关键索引:确保
user_id,org_id,owner_id等过滤字段建立索引。
- 避免在十万级数据表上使用
- 避免硬编码SQL片段:像
getDataPermissionSQL()返回字符串的方式容易导致注入和安全风险。推荐使用ORM的Query Builder来动态构建条件,如$query->whereIn(...)或$query->where(...)。 - 考虑使用现成的权限包:
- Laravel:
Spatie/laravel-permission(主要做功能权限,可以辅助做数据权限的逻辑判断)。 - 通用PHP: 结合
DoctrineORM 的过滤器(Filters)。
- Laravel:
最核心的思想就是:在数据查询语句被执行前,强制插入一条WHERE条件,这个条件基于当前已验证的用户身份。