成本数据权限、隐私与敏感信息
成本平台上线后,某团队拿到“只读报表”权限,却通过导出接口下载了其他业务线的资源 ID、真实标签、租户规模和合同折扣。页面确实隐藏了单价列,底层 API 却仍返回完整记录;BI 缓存、浏览器下载目录和工单附件又复制出三份数据。管理员撤销页面角色后,以为访问已经终止,但旧访问令牌、对象存储预签名链接和导出文件仍然有效。
成本数据不是普通监控指标。账号结构能暴露组织边界,资源与标签能反推出产品和客户,使用量能暴露业务规模,折扣、承诺和发票能暴露采购条件与利润空间。安全设计必须回答四件事:谁以什么身份访问,能看到哪一行和哪一列,为何被允许,数据在何处留下副本以及如何证明它已被回收。
先画清成本数据的信任边界
一条账单记录常同时含有五类敏感事实:
| 数据层 | 典型字段 | 泄露后果 | 默认处理 |
|---|---|---|---|
| 账号与组织 | billing account、subscription、project、cost center | 暴露组织和法人结构 | 限制在财务与平台域 |
| 资产与工作负载 | resource ID、cluster、namespace、标签 | 暴露系统拓扑、客户或项目名称 | 稳定 ID 映射,按 owner 行过滤 |
| 使用与规模 | CPU、存储、流量、请求量 | 反推业务峰值与容量 | 聚合到批准粒度 |
| 商务与合同 | effective rate、discount、commitment、credit | 暴露采购条件和议价能力 | 列级遮蔽,采购或财务专属 |
| 财务与结算 | invoice、tax、legal entity、chargeback | 暴露正式会计事实 | 独立审批和不可变审计 |
原始账单区、规范化成本区、分摊结果区、报表语义层和临时导出区不能共享一套“数据分析员”角色。原始区保存供应商交付事实,权限最窄;规范化区用于清洗与对账;分摊区增加 owner、成本中心和政策版本;语义层只投影读者真正需要的字段;导出区是有时限、有目的、可撤销的副本。
provider export
-> encrypted raw zone
-> normalized cost facts
-> owner and policy mapping
-> authorized semantic view
-> dashboard / API / expiring export访问控制必须放在查询服务、仓库视图和对象存储策略上。前端隐藏列、仪表盘过滤器和文件命名约定都不是安全边界。
用稳定身份表达访问意图
权限主体分为人、工作负载和紧急身份。人通过企业身份提供方进入用户组;采集器、转换作业和报表服务使用独立工作负载身份;紧急身份默认禁用,只在审批后短时激活。共享账号会让审计记录失去责任主体,也无法在人员离职时精确回收。
访问策略至少绑定这些属性:
policyId: finops-cost-read
version: "v3"
principal:
type: workforce
groups: [product-cost-owner]
resource:
dataset: allocated_cost
allowedOwners: ["${principal.owner_id}"]
columns:
allow: [charge_period, service, workload, effective_cost, currency]
deny: [billing_account, contract_rate, discount_id, tenant_label]
purpose: showback-review
approval:
owner: finops-data-owner
expiresAfter: "<approved-duration>"
export:
enabled: false
audit:
decisionLog: required
queryDigest: requiredgroups 只表达角色,allowedOwners 决定行边界,columns 决定列边界,purpose 说明使用目的,expiresAfter 防止临时授权变成永久授权。导出是一项独立能力;能查询聚合结果不等于能下载明细。
FinOps Framework 的 Security capability 将访问控制、数据保护和安全风险纳入成本管理。云平台优先使用 AWS IAM role、Azure managed identity、Google Cloud Workload Identity Federation 或等价短期身份,避免把长期访问密钥写入 Helm values、CI 变量文件或 BI 数据源连接串。
在本地建立一条可验证的授权链
下面使用 PostgreSQL 演示行级与列级边界。实验库只放虚构记录,不接真实云账单。启动临时实例:
docker run --name finops-access-lab --rm -d \
-e POSTGRES_PASSWORD='<lab-password>' \
-p 127.0.0.1:55432:5432 postgres:17-alpine
docker exec -i finops-access-lab psql -U postgres <<'SQL'
CREATE DATABASE finops_lab;
SQL在 finops_lab 建立原始表、会话上下文和授权视图:
CREATE SCHEMA billing_raw;
CREATE SCHEMA billing_api;
CREATE TABLE billing_raw.cost_fact (
charge_id text PRIMARY KEY,
owner_id text NOT NULL,
service_name text NOT NULL,
effective_cost numeric(18,6) NOT NULL,
currency char(3) NOT NULL,
billing_account text NOT NULL,
contract_rate numeric(18,6),
tenant_label text
);
INSERT INTO billing_raw.cost_fact VALUES
('charge-a', 'team-a', 'compute', 18.40, 'USD', 'account-a', 0.071, 'tenant-redacted-a'),
('charge-b', 'team-b', 'storage', 9.20, 'USD', 'account-b', 0.052, 'tenant-redacted-b');
CREATE ROLE cost_reader NOLOGIN;
REVOKE ALL ON SCHEMA billing_raw FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA billing_raw FROM PUBLIC;
CREATE VIEW billing_api.owner_cost
WITH (security_barrier = true) AS
SELECT charge_id, owner_id, service_name, effective_cost, currency
FROM billing_raw.cost_fact
WHERE owner_id = current_setting('app.owner_id', true);
GRANT USAGE ON SCHEMA billing_api TO cost_reader;
GRANT SELECT ON billing_api.owner_cost TO cost_reader;security_barrier 让过滤条件在调用方表达式之前生效。生产系统还应由连接代理或查询 API 根据已验证令牌设置 app.owner_id;不能接受浏览器随意传入 owner。原始表不给业务角色授权,因此即使猜到表名也不能绕过投影视图。
正向实验:业务 owner 只看到自己的成本
管理员模拟已认证的 team-a 会话:
BEGIN;
SET LOCAL ROLE cost_reader;
SELECT set_config('app.owner_id', 'team-a', true);
SELECT * FROM billing_api.owner_cost ORDER BY charge_id;
COMMIT;预期只返回 charge-a,且结果中没有 billing_account、contract_rate 和 tenant_label。再切换到 team-b,结果只能包含 charge-b。验收不能只看行数,还要检查返回列集合、查询身份、策略版本和审计事件。
查询服务应产生这样的决策证据:
{
"principal": "workforce:<subject-id>",
"purpose": "showback-review",
"ownerScope": "team-a",
"dataset": "allocated_cost",
"columnsPolicy": "finops-cost-read-v3",
"decision": "allow",
"queryDigest": "sha256:<digest>",
"resultRows": 1,
"exportId": null
}日志记录稳定主体和查询摘要,不记录完整 SQL 参数、真实账单行或访问令牌。安全团队需要知道“谁查了什么类型的数据”,不需要在审计系统再复制一份敏感账单。
反向实验:页面过滤不能阻止底层越权
先验证业务角色无法读取原始表:
BEGIN;
SET LOCAL ROLE cost_reader;
SELECT set_config('app.owner_id', 'team-a', true);
SELECT billing_account, contract_rate
FROM billing_raw.cost_fact;
ROLLBACK;预期得到 permission denied for schema billing_raw 或等价拒绝。若查询成功,说明团队只在页面层做了隐藏,必须立即撤销原始 schema/table 授权,并检查过去的查询与导出记录。
再模拟错误策略:开发者把 owner_id 作为 URL 参数拼到查询中,却没有将它与登录主体映射。攻击者把 owner=team-b 传给 API 后可以读取其他团队记录。稳定的修复不是“把参数框隐藏”,而是由服务端从受信任身份声明解析 owner,并拒绝主体与 owner 映射不一致的请求:
token subject -> identity directory -> approved owner set
request owner -> must be subset of approved owner set
query session -> owner set + policy version + purpose反向验收应覆盖改写 owner、请求禁用列、直接访问 raw endpoint、复用过期令牌、重复使用预签名链接和超大批量查询。每次拒绝都要产生 deny reason,否则团队只知道“403”,无法判断是身份、策略、数据域还是授权到期。
导出是一次新的数据发布
导出文件会脱离在线查询边界,应采用单独审批、最小列、行数上限、加密、TTL 和单次下载。服务先把数据写入隔离前缀,再返回短时预签名 URL;URL 不进入聊天、工单或普通应用日志。
exportRequest:
purpose: quarterly-cost-review
ownerScope: [team-a]
columns: [service_name, effective_cost, currency]
format: parquet
maxRows: 50000
expiresAfter: "<approved-duration>"
classification: confidential-cost
watermark: "<source-watermark>"导出服务不应接受任意 bucket、任意 KMS key 或调用方提供的文件路径。对象存储策略只允许服务身份写入固定前缀,读取者只能读取对应 exportId;生命周期规则负责到期删除,审计作业验证对象、版本和临时副本都已消失。
AWS Cost and Usage Reports 可写入受控 S3,权限与加密应按 CUR security guidance 隔离;Google Cloud Billing export 读取者需要对目标 BigQuery 数据集单独授权,参考 Cloud Billing access control;Azure 成本数据访问由 billing scope 与 Azure RBAC 共同决定,参考 Cost Management data access。拥有云资源读取权不自动等于拥有账单和合同数据读取权。
KMS、凭证和密钥轮换要能证明旧路径失效
原始区、仓库、缓存和导出区分别使用可审计的加密边界。信封加密中的数据密钥由平台生成,KMS 主密钥只授权给指定服务身份;开发者不直接下载数据密钥。轮换不是创建新 key 后结束,还要验证新写入使用新版本、旧数据仍可按保留策略读取、旧服务身份已撤销、回滚窗口结束后旧 key 不再被调用。
凭证清单至少区分:云账单采集身份、对象存储身份、转换作业身份、仓库加载身份、查询 API 身份、BI 服务身份和导出服务身份。一个“finops-admin”贯穿全部链路,会同时拥有读取合同、改写数据、发布报表和删除证据的能力。
故障定位按第一条证据分型:
| 现象 | 第一条证据 | 常见原因 | 修复与复验 |
|---|---|---|---|
| API 返回 403 | 策略 decision log | owner 映射缺失、授权到期、purpose 不匹配 | 修复身份映射,不直接加全局角色;重放同一请求 |
| 页面隐藏列但导出含敏感列 | export manifest | 投影策略未复用、旧模板缓存 | 撤销链接、删除对象、统一列策略并复导 |
| KMS decrypt 被拒绝 | key audit event | 服务身份或 key version 错误 | 修正 grant,验证旧身份仍被拒绝 |
| 撤权后仍可下载 | URL 与对象访问日志 | 预签名 TTL 过长、CDN/缓存副本 | 吊销对象或 key、清缓存、确认后续访问拒绝 |
| 审计日志含账单明细 | 日志字段抽样 | SQL/响应体被完整记录 | 改为摘要和分类字段,清理受影响日志 |
容量与成本也受安全设计影响
行列过滤、策略决策、审计和加密都会消耗资源。高基数 owner 条件缺少合适分区或索引时,业务团队会转而申请全量导出;审计同步写入缓慢时,查询线程可能堆积;每行调用外部策略服务会把网络延迟乘到整个结果集。
容量模型应测量:每日账单行数、活跃 owner 数、并发查询、扫描字节、导出峰值、审计事件量、策略缓存命中率、KMS 调用量和删除队列年龄。授权决策按查询级执行,行过滤下推到仓库;策略缓存键必须包含主体、owner、purpose、策略版本和授权到期,不能只按用户缓存。
安全成本不能通过关闭审计来节省。更合理的做法是把完整访问事件写入低延迟缓冲,异步进入不可变审计库;查询链在审计不可用时根据数据等级选择拒绝或受控降级,并对缺失事件产生告警。
删除、保留与法律义务是一条状态机
成本数据通常同时受财务保留、合同、审计、隐私和争议处理约束。删除请求不能直接从源表抹除一行后宣布完成。先识别该主体在原始账单、映射表、缓存、报表、导出、审计和备份中的所有引用,再判断哪些记录必须保留、哪些可去标识、哪些必须删除。
requested -> identity resolved -> locations enumerated
-> legal/finance hold checked -> delete or pseudonymize
-> indexes/cache/export purged -> backup expiry recorded
-> independent verification -> closed删除证明至少包含请求 ID、主体的受控哈希、数据位置、策略依据、执行身份、对象版本、删除结果、保留例外和复核人。为了证明删除而把原始敏感字段复制到删除工单,会制造新的泄露面。
策略升级、回滚与平台退出
权限策略升级先对历史审计流做 shadow evaluation,比较新旧版本的 allow/deny 差异。新增允许项必须说明业务目的与到期;新增拒绝项要评估报表、API、自动任务和应急访问。上线后若误拒绝,可以回滚策略版本,但不能恢复已撤销的长期凭证;若误放行,先封闭入口、撤销 token 与导出链接,再分析访问和复制范围。
退出成本平台时,先停止新授权和新导出,导出必要的策略版本、身份映射、访问决策、导出清单、保留与删除证明;替代平台使用相同主体和测试矩阵验证行列边界。切流后撤销采集、仓库、BI、KMS 和对象存储身份,删除临时前缀与缓存,等待备份按批准策略到期。最后用已撤销身份、过期链接和跨 owner 查询做反向验证,证明旧路径不再可达。
架构决策检查
原始账单、规范化数据、分摊结果、语义层和导出区是否分别授权。人员与工作负载是否使用可追责、短期、可轮换的身份。行、列、purpose、到期和导出能力是否共同进入策略决策。
页面、API、仓库与导出是否复用同一授权语义,而非各自维护过滤器。审计是否记录主体、策略、查询摘要、结果规模和导出 ID,同时避免复制敏感明细。预签名链接、缓存、日志、备份和本地副本是否进入撤权与删除路径。
策略升级是否经过差异评估,退出是否能证明凭证、对象和旧访问链全部核销。
当团队能从任意一条成本查询追溯到稳定身份、策略版本、允许字段、owner 边界、访问目的和到期状态,并能在撤权后证明所有副本不可再访问,成本数据才真正进入了可治理状态。
