一、问题背景:为什么仪表盘会泄露敏感数据

想象一下,你给团队搭了一个 Metabase 仪表盘,用来展示每天的销售情况。刚开始只有几个核心同事在用,大家数据权限都差不多,一切平稳。后来随着公司发展,运营、客服、外部合作伙伴也加入了,这时候问题来了:某个同事打开订单明细表,一眼就看到了客户的手机号、家庭住址,甚至财务同事的薪资数据。你可能会说,我可以给不同人设置不同的权限啊,但 Metabase 的权限只能控制到“谁能看哪个表”,没法做到同一张表里,A 能看到完整的手机号,B 只能看到脱敏后的号码。这就很尴尬了。

为什么会这样?因为 Metabase 在设计上是一个轻量级的 BI 工具,它把“数据访问”这件事简化成了“给用户分配数据源和表”。它不像一些重量级的企业 BI 平台,自带列级和行级的安全控制。所以,当我们要在 Metabase 上实现更细粒度的数据保护,就得靠自己想办法。

那这个“办法”的核心思路是什么?就是动态脱敏。

二、动态脱敏的核心思路:先挡住,再放行

动态脱敏是什么意思?用一句大白话说,就是“看人下菜碟”。当用户发起查询时,系统先看看这个人是谁、属于哪个角色,然后根据他的身份动态决定返回什么样的数据。比如,销售总监可以看到客户完整的电话号码,而客服专员只能看到前三位和后四位,中间用星号代替。

这种做法的好处是,数据在源头就被处理了,到了用户面前已经是“处理过的版本”。即使有人把 SQL 语句复制出来自己跑,得到的结果也是一样的,不会绕过脱敏。这就像在自助餐厅,食物本身已经被处理成半份,你不可能拿到一整只鸡。

那我们怎么在 Metabase 里实现这个“看人下菜碟”呢?最常用的方式,就是让 Metabase 在查询的时候把当前用户的信息传给数据库,数据库根据这个信息决定脱不脱敏。下面我们来看一个具体的例子。

三、实战:用 PostgreSQL + Metabase 做一个简单的动态脱敏

为了把整个过程讲清楚,我们用一个最常用的组合:PostgreSQL 作为数据库,Metabase 作为 BI 工具。技术栈统一为 PostgreSQL 和 Metabase 自带的功能,涉及 SQL 脚本和 Metabase 界面配置。整个方案不需要额外写一行 Java 或 Python,纯数据库和 BI 配置就能搞定。

3.1 先建一张业务表

假设我们有一张“客户表”,里面存了客户的基本信息和手机号。我们先用 SQL 把这张表建出来,并插入几条测试数据。为了方便观察,我们故意让三个人的手机号各不相同。

-- 技术栈:PostgreSQL

-- 创建一个客户表
CREATE TABLE customer (
    id SERIAL PRIMARY KEY,          -- 客户ID
    name VARCHAR(50),               -- 客户姓名
    phone VARCHAR(20),              -- 手机号码
    city VARCHAR(50)                -- 所在城市
);

-- 插入几条测试数据
INSERT INTO customer (name, phone, city) VALUES
('张三', '13812345678', '北京'),
('李四', '13987654321', '上海'),
('王五', '13711112222', '广州');

这张表很简单,但已经足够说明问题了。假设手机号是敏感字段,我们不希望所有人都能看到完整的手机号。

3.2 在 Metabase 里设置用户属性

要让数据库知道当前登录 Metabase 的“人是谁”,我们需要在 Metabase 里给用户设置一个“属性”。比如,给每个用户加一个 role 属性,值可以是 admin(管理员)或 normal(普通用户)。这个属性可以在查询时作为参数传进去。

具体操作是:进入 Metabase 后台,找到用户管理,编辑用户信息,在“属性”区域添加一个键值对,比如 role 的值设为 admin 或者 normal。这一步是纯界面操作,不需要写代码。但注意,这需要你以管理员身份登录 Metabase。

你可能会问:为什么不用 Metabase 自带的“分组”呢?因为分组的粒度是表级别的,只能决定用户能否访问某张表,不能决定表中某列是否打码。用户属性则是更灵活的东西,它像一张属于每个人的“小纸条”,写什么内容由你决定。我们正是利用这个小纸条,把角色信息带进 SQL 里。

3.3 写一个脱敏函数

接下来,我们在 PostgreSQL 里写一个函数,用来根据角色决定是否脱敏。这个函数接收两个参数:原始手机号和当前用户角色。如果角色是 admin,返回原始号码;否则返回打码后的号码。电话脱敏的规则,我们做成保留前三位和后四位,中间用星号代替。

-- 技术栈:PostgreSQL

-- 创建打码函数
CREATE OR REPLACE FUNCTION mask_phone(raw_phone TEXT, user_role TEXT)
RETURNS TEXT AS $$
BEGIN
    -- 如果是admin角色,直接返回原始号码
    IF user_role = 'admin' THEN
        RETURN raw_phone;
    ELSE
        -- 否则返回前三位 + **** + 后四位
        RETURN left(raw_phone, 3) || '****' || right(raw_phone, 4);
    END IF;
END;
$$ LANGUAGE plpgsql;

这个函数很简单,但思路很重要:所有对敏感字段的处理都集中在这个函数里,以后想改规则,只需要改这里,不需要动业务逻辑。比如你想把中间用 **** 改成 ***,或者保留后两位,都很容易。

3.4 创建一个“会看人”的视图

有了函数之后,我们需要让查出来的手机号自动套用这个函数。这里我们创建一个视图,视图逻辑是:先获取当前会话里的用户角色(由 Metabase 在查询时设置),然后调用脱敏函数。

-- 技术栈:PostgreSQL

-- 创建脱敏视图
CREATE OR REPLACE VIEW customer_masked AS
SELECT
    id,
    name,
    -- 根据当前会话中的角色,决定显示完整手机号还是脱敏手机号
    mask_phone(phone, current_setting('myapp.role', true)) AS phone,
    city
FROM customer;

这里用到一个 PostgreSQL 的内置函数 current_setting,它用来获取会话级别的参数。第二个参数 true 表示如果参数不存在,不报错而是返回 NULL。我们在 Metabase 的查询里,会先执行一句 set_config('myapp.role', '用户角色', false) 来设置这个会话参数。这样,同一个视图,在不同用户登录时,看到的结果就不一样。

3.5 在 Metabase 里写一个自定义查询

接下来我们在 Metabase 里新建一个查询,用“原生查询”(也就是写 SQL)的方式,从那个视图取数据。重点在于,我们要在 SQL 里先设置角色参数。

假设 Metabase 有一个变量叫做 {{role}},这个变量会自动取当前用户的 role 属性。那么我们的查询就可以这样写:

-- 技术栈:PostgreSQL

-- 把当前登录用户的role属性设置到数据库会话变量中
SELECT set_config('myapp.role', '{{role}}', false);

-- 查询脱敏视图
SELECT id, name, phone, city FROM customer_masked;

注意,{{role}} 是 Metabase 的模板变量语法。当你运行这个查询时,Metabase 会把它替换成当前用户的属性值。例如,用户张三在 Metabase 里的 role 属性是 admin,那么实际执行的 SQL 就是:

SELECT set_config('myapp.role', 'admin', false);
SELECT id, name, phone, city FROM customer_masked;

而如果李四的 role 属性是 normal,实际执行的就是:

SELECT set_config('myapp.role', 'normal', false);
SELECT id, name, phone, city FROM customer_masked;

这样一来,两个用户看到的 phone 字段就不一样了。我们来看看效果。

3.6 验证效果:管理员看全部,普通人看星号

为了验证,我们可以直接在 psql 里模拟一下。先把会话角色设为管理员,再查视图:

-- 技术栈:PostgreSQL

-- 模拟Metabase中admin角色的查询
SET myapp.role = 'admin';
SELECT * FROM customer_masked;

执行结果会是这样:

 id | name |  phone     | city
----+------+------------+------
  1 | 张三 | 13812345678 | 北京
  2 | 李四 | 13987654321 | 上海
  3 | 王五 | 13711112222 | 广州

再模拟普通用户:

-- 技术栈:PostgreSQL

-- 模拟Metabase中normal角色的查询
SET myapp.role = 'normal';
SELECT * FROM customer_masked;

结果变成:

 id | name | phone       | city
----+------+-------------+------
  1 | 张三 | 138****5678 | 北京
  2 | 李四 | 139****4321 | 上海
  3 | 王五 | 137****2222 | 广州

完美!不同的角色,看到的数据不同,脱敏在数据库层面就完成了。

四、这个方案有什么优缺点?

每个方案都不是银弹。我们自己搭的这个动态脱敏方案,好处和坏处都很明显。

先说优点:

第一,脱敏逻辑集中在数据库层,只要 Metabase 跑的是我们写好的 SQL,就一定会经过脱敏函数。用户无法通过修改查询语句来绕过,因为在数据库内部,视图已经对敏感字段做了处理。

第二,实现成本低。不需要额外部署一个代理服务,也不需要改造 Metabase 本身,只需要写几个 SQL 函数和视图,在 Metabase 里设置一个用户属性。

第三,灵活度高。你可以根据需要扩展脱敏规则,比如对身份证号、银行卡号做不同的处理。甚至可以结合行级权限,让不同角色只能看到特定城市的订单。

再说缺点:

第一,依赖 Metabase 的用户属性。如果用户属性设置不对,或者用户没有设置角色,current_setting('myapp.role', true) 会返回 NULL,在脱敏函数里,因为 NULL 不等于 'admin',所以会走脱敏分支,也就是说,没设置角色的用户默认看到脱敏数据,这其实还算安全。但如果你把默认行为弄反了,就可能出问题。

第二,Metabase 在“原生查询”里使用 set_config 这种方式,相当于每次查询前都要重置会话。如果 Metabase 的数据库连接池复用了同一个会话,可能带来一些隐患。不过 PostgreSQL 的 set_config 第三个参数设为 false 时,设置的是会话级变量,只对当前会话生效。但我们仍然需要确保变量设置和查询在同一个连接里执行。上面我们是在同一个 SQL 批处理中先设置再查询,Metabase 会按顺序执行,所以没问题。

第三,管理成本会增加。当业务表很多、敏感字段也很多时,你得为每个敏感视图写相应的脱敏函数,还要维护用户角色属性。如果团队人员变动频繁,还要记得在 Metabase 里更新角色。

五、注意事项:别让脱敏变成“掩耳盗铃”

在使用这种方案时,有几个坑你必须留意。

第一,不要在 Metabase 的“表权限”中直接暴露原始表。如果某个用户能直接访问 customer 表,那他只要自己写一条 SQL,绕过视图,就能看到原始数据了。所以,在 Metabase 的权限设置里,要禁止普通用户直接浏览原始表,只能让他们访问我们创建的脱敏视图或者自定义查询。

第二,注意自定义查询里的变量注入问题。{{role}} 这种变量,如果用户自己也能编写查询,他可能会把变量替换成任意值,比如手动改成 'admin',从而看到完整数据。怎么避免?一是 Metabase 里尽量只给用户分配“卡片”和“仪表盘”权限,不给他们编写原生命令的权限。二是把脱敏逻辑放到更底层,也就是数据库视图里,然后通过固定角色访问。但这里有个矛盾,数据库视图依赖会话变量,而会话变量又来自 Metabase 的模板变量。如果用户是管理员,他可以编辑查询,把 {{role}} 改成 'admin'。所以真正的解决方案是,不让普通用户拥有原生查询的编辑权限,只允许运行我们预先准备好的“卡片”。

第三,谨慎处理 Metabase 的“用户分组”。建议把用户分成不同的组,比如“管理员组”和“普通员工组”,在用户管理里清晰分配角色属性。同时,定期检查是否有用户被误分了角色。

第四,如果你的数据量很大,把脱敏函数放在查询条件中可能导致索引失效。比如如果我们对某字段做了脱敏,就不能直接用来过滤。不过这个问题不大,因为脱敏通常只发生在返回结果中,不会影响 WHERE 条件。在视图里我们保留了原始字段,只是通过函数生成 phone,所以索引还是有效的。

第五,要考虑备份库和副本库的问题。如果你的 Metabase 连接的是数据仓库的副本,而副本又由其他系统维护,那么这里的脱敏方案可能无法生效。一定要确保,所有的查询入口都经过同一个脱敏视图。

六、进阶想法:把它推广到更多场景

动态脱敏不只是保护手机号,还可以做很多事。比如,我们可以再创建一个视图,针对不同的角色隐藏薪资字段。假设我们有一张员工表:

-- 技术栈:PostgreSQL

-- 员工表
CREATE TABLE employee (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50),
    salary NUMERIC
);

-- 插入示例数据
INSERT INTO employee (name, salary) VALUES
('赵六', 8000),
('钱七', 12000),
('孙八', 20000);

然后写一个更通用的脱敏视图:

-- 技术栈:PostgreSQL

CREATE OR REPLACE VIEW employee_view AS
SELECT
    id,
    name,
    CASE
        WHEN current_setting('myapp.role', true) = 'admin' THEN salary
        ELSE NULL                     -- 非管理员看不到薪资,直接显示NULL
    END AS salary
FROM employee;

这样,普通用户看到薪资是空的,而管理员看得到真实数字。

甚至,你还可以根据角色去过滤行,让总经理看到全公司数据,普通经理只看到自己部门的数据。这就是“行级安全”的范畴了,Metabase 的权限本身是做不到的,但数据库视图和会话变量组合起来就能实现。

再举个例子。假设我们有一个订单表,里面记录了“所属区域”。我们可以创建一个视图,让不同区域的销售经理只能看到自己区域的订单。这个逻辑可以写在视图的 WHERE 条件里:

-- 技术栈:PostgreSQL

CREATE OR REPLACE VIEW order_region_view AS
SELECT *
FROM orders
WHERE region = current_setting('myapp.region', true);

配合 Metabase 里的 region 用户属性,就能实现行级的数据隔离。这样,动态脱敏和数据权限就一起搞定了。

七、文章总结

总结一下,用 Metabase 做业务仪表盘,敏感数据泄露是真实存在的风险,但我们可以通过“动态脱敏”来应对。核心做法是把“用户是谁”和“数据该不该脱敏”这两个信息交给数据库,在数据库层面根据角色动态生成结果。借助 PostgreSQL 的视图、函数和会话变量,再配合 Metabase 的用户属性,我们可以在不引入额外服务的情况下,实现一套可用的动态脱敏机制。

当然,这个方案不是万能的。它不是 Metabase 官方提供的特性,而是我们利用数据库能力“造”出来的。因此,需要我们在权限配置、查询管理和人员维护上多留点心。如果公司对数据安全要求极高,且预算充足,可以考虑专业的 BI 数据安全产品。但对于大多数中小团队来说,上面这套方法已经足够实用,而且性价比很高。希望大家能举一反三,把自己的数据保护好。

最后再提醒一句:不管用不用这个方案,都要记住“权限最小化”原则。给每个用户最小够用的权限,给每份数据做最合理的脱敏,这样才能让仪表盘真正成为业务的好帮手,而不是数据的“泄洪口”。