HR:部門人力與薪酬名冊
情境
ACME 的人資主管要把分散的員工、薪酬與編制試算表搬進 TeamSync。公司負責人需要看全公司資料;各部門主管只能管理自己的部門;HR 專員可以看台灣區員工,但不應取得薪資。這個設計把資料掛在部門 scope,讓「哪個部門擁有資料」與「某位使用者能看哪些資料列、欄位」成為兩個清楚的控制層。
資料模型
- Employees(部門)
Name:string、Region:string、Salary:floatLevel:select(Junior / Senior / Lead)與Cost Center:stringAnnual Salary:formula = [Salary] * 12
- Payroll Register(部門)
Name、Region、Salary- 用
default_permissions決定同部門成員的基線權限
- Headcount Roster(聊天室)
Name、Region、Salary- 可依部門授權給聊天室外的部門成員
部門主管 ──完整管理──▶ Employees / Payroll Register
HR 專員 ──Region=TW──▶ Employees ──公式──▶ Annual Salary
其他部門 ──無 grant──▶ 不可讀取Note
Annual Salary依賴Salary。當薪資欄位對非主管隱藏時,公式結果會是null,不會把薪資換一種形式洩漏出去。
整份名冊就是一份 IaC 文件
三張 table、兩個讓「我的資料列」得以表達的人員欄位、薪酬異動的簽核閘、欄位允許清單、各角色會打開的 view,以及所有 grant,全部寫在同一份 JSONL 裡。把它貼進 IaC 工作台,先跑 iac.plan 讀過每一個 action,再把逐位元組相同的文件連同回傳的 plan_hash 送給 iac.apply。
套用前有四件事跟環境綁在一起,必須先改掉。$dept: 與 $user: token 指的是貴公司真實存在的部門與帳號名稱,請把 ACME Taiwan、Finance、lin.manager、hr.specialist、mei.wang 換掉。require_approval 規則的 template_id 必須是同公司內一個可用的 Review 範本,下面那個 placeholder UUID 是解析不出來的。department_id cell 裝的是部門 UUID 而不是名稱,因為 $me.department 解析出來的就是 UUID。最後,record 行會種下兩位員工與一列編制資料,請自行替換或刪除。
{"kind":"header","version":1,"system":"acme-hr","description":"ACME 的人員、薪酬與編制名冊"}
{"kind":"table","ref":"employees","spec":{"name":"ACME Employees","description":"HR 擁有的員工名冊","key":"employee_no","settings":{"default_permissions":{"can_read":"none","can_insert":false,"can_edit":"none"}}}}
{"kind":"column","table":"employees","ref":"employee_no","spec":{"name":"Employee No","type":"string","required":true}}
{"kind":"column","table":"employees","ref":"full_name","spec":{"name":"Name","type":"string","required":true}}
{"kind":"column","table":"employees","ref":"employee","spec":{"name":"Employee Account","type":"user","description":"這一列所描述的人"}}
{"kind":"column","table":"employees","ref":"manager","spec":{"name":"Approving Manager","type":"user","description":"簽核薪酬異動的主管"}}
{"kind":"column","table":"employees","ref":"department_id","spec":{"name":"Department Id","type":"string","description":"部門 UUID,$me.department 比對的就是這個欄位"}}
{"kind":"column","table":"employees","ref":"region","spec":{"name":"Region","type":"string"}}
{"kind":"column","table":"employees","ref":"level","spec":{"name":"Level","type":"select","options":["Junior","Senior","Lead"]}}
{"kind":"column","table":"employees","ref":"cost_center","spec":{"name":"Cost Center","type":"string"}}
{"kind":"column","table":"employees","ref":"salary","spec":{"name":"Salary","type":"float"}}
{"kind":"column","table":"employees","ref":"annual_salary","spec":{"name":"Annual Salary","type":"formula","expression":"[salary] * 12"}}
{"kind":"table","ref":"payroll","spec":{"name":"Payroll Register","description":"每位員工每個薪資期間一列","key":"payroll_no","settings":{"default_permissions":{"can_read":"none","can_insert":false,"can_edit":"none"}}}}
{"kind":"column","table":"payroll","ref":"payroll_no","spec":{"name":"Payroll No","type":"string","required":true}}
{"kind":"column","table":"payroll","ref":"period","spec":{"name":"Period","type":"string","max_length":7}}
{"kind":"column","table":"payroll","ref":"employee_row","spec":{"name":"Employee","type":"link","target":"employees","cardinality":"one"}}
{"kind":"column","table":"payroll","ref":"employee","spec":{"name":"Employee Account","type":"user"}}
{"kind":"column","table":"payroll","ref":"department_id","spec":{"name":"Department Id","type":"string"}}
{"kind":"column","table":"payroll","ref":"gross","spec":{"name":"Gross","type":"float"}}
{"kind":"column","table":"payroll","ref":"deductions","spec":{"name":"Deductions","type":"float"}}
{"kind":"column","table":"payroll","ref":"net","spec":{"name":"Net","type":"formula","expression":"[gross] - [deductions]"}}
{"kind":"column","table":"payroll","ref":"region","spec":{"name":"Region","type":"lookup","link_field":"employee_row","target_column":"region"}}
{"kind":"table","ref":"roster","spec":{"name":"Headcount Roster","description":"聊天室可見的編制,不含薪酬","key":"seat_no","settings":{"default_permissions":{"can_read":"all","can_insert":false,"can_edit":"none"}}}}
{"kind":"column","table":"roster","ref":"seat_no","spec":{"name":"Seat No","type":"string","required":true}}
{"kind":"column","table":"roster","ref":"employee","spec":{"name":"Employee Account","type":"user"}}
{"kind":"column","table":"roster","ref":"department_id","spec":{"name":"Department Id","type":"string"}}
{"kind":"column","table":"roster","ref":"region","spec":{"name":"Region","type":"string"}}
{"kind":"column","table":"roster","ref":"level","spec":{"name":"Level","type":"select","options":["Junior","Senior","Lead"]}}
{"kind":"column","table":"roster","ref":"open_seat","spec":{"name":"Open Seat","type":"boolean","default_value":false}}
{"kind":"column","table":"employees","ref":"payroll_runs","spec":{"name":"Payroll Runs","type":"rollup","direction":"incoming","source":"payroll","match":{"employee_row":"$self"},"aggregation":"count"}}
{"kind":"column","table":"employees","ref":"ytd_gross","spec":{"name":"YTD Gross","type":"rollup","direction":"incoming","source":"payroll","match":{"employee_row":"$self"},"aggregation":"sum","target_column":"gross"}}
{"kind":"rule","table":"employees","ref":"unique_employee_no","spec":{"type":"unique","name":"Employee number is unique","columns":["employee_no"],"case_insensitive":true}}
{"kind":"rule","table":"employees","ref":"manager_is_not_self","spec":{"type":"compare","name":"A manager cannot approve their own row","left":"manager","op":"neq","right":"employee"}}
{"kind":"rule","table":"employees","ref":"compensation_needs_approval","spec":{"type":"require_approval","name":"Compensation changes need review","label":"Compensation change","events":["update"],"template_id":"44444444-4444-4444-8444-444444444444","when":[{"column":"salary","op":"is_not_null"}]}}
{"kind":"view","table":"employees","ref":"my_record","spec":{"name":"My record","is_shared":true,"config":{"sort_by":"employee_no","sort_order":"asc","columns":["employee_no","full_name","employee","manager","region","level","cost_center"]}}}
{"kind":"view","table":"employees","ref":"department_register","spec":{"name":"Department register","is_shared":true,"config":{"sort_by":"full_name","sort_order":"asc","columns":["employee_no","full_name","employee","manager","region","level","cost_center","salary","annual_salary","payroll_runs","ytd_gross"]}}}
{"kind":"view","table":"payroll","ref":"finance_run","spec":{"name":"Finance payroll run","is_shared":true,"config":{"sort_by":"payroll_no","sort_order":"desc","columns":["payroll_no","period","employee","region","gross","deductions","net"]}}}
{"kind":"view","table":"roster","ref":"open_seats","spec":{"name":"Open seats","is_shared":true,"config":{"filters":{"open_seat":true},"sort_by":"seat_no","sort_order":"asc","columns":["seat_no","region","level","open_seat"]}}}
{"kind":"grant","table":"employees","principal":{"type":"department","id":"$dept:ACME Taiwan"},"spec":{"can_read":"filtered","can_insert":false,"can_edit":"none","visible_columns":["employee_no","full_name","employee","manager","department_id","region","level","cost_center"],"read_filter":{"and":[{"column":"employee","op":"eq","value":"$me"}]}}}
{"kind":"grant","table":"employees","principal":{"type":"user","id":"$user:lin.manager"},"spec":{"can_read":"filtered","can_insert":true,"can_edit":"filtered","read_filter":{"and":[{"column":"department_id","op":"eq","value":"$me.department"}]},"edit_filter":{"and":[{"column":"department_id","op":"eq","value":"$me.department"}]}}}
{"kind":"grant","table":"employees","principal":{"type":"user","id":"$user:hr.specialist"},"spec":{"can_read":"filtered","can_insert":false,"can_edit":"filtered","visible_columns":["employee_no","full_name","employee","department_id","region","level","cost_center"],"read_filter":{"and":[{"column":"region","op":"eq","value":"TW"}]},"edit_filter":{"and":[{"column":"region","op":"eq","value":"TW"}]}}}
{"kind":"grant","table":"payroll","principal":{"type":"department","id":"$dept:Finance"},"spec":{"can_read":"all","can_insert":true,"can_edit":"all"}}
{"kind":"grant","table":"payroll","principal":{"type":"department","id":"$dept:ACME Taiwan"},"spec":{"can_read":"filtered","can_insert":false,"can_edit":"none","visible_columns":["payroll_no","period","employee","gross","deductions","net"],"read_filter":{"and":[{"column":"employee","op":"eq","value":"$me"}]}}}
{"kind":"grant","table":"roster","principal":{"type":"department","id":"$dept:ACME Taiwan"},"spec":{"can_read":"filtered","can_insert":false,"can_edit":"none","read_filter":{"and":[{"column":"department_id","op":"eq","value":"$me.department"}]}}}
{"kind":"record","table":"employees","data":{"employee_no":"E-1001","full_name":"Mei Wang","employee":"$user:mei.wang","manager":"$user:lin.manager","department_id":"11111111-1111-4111-8111-111111111111","region":"TW","level":"Senior","cost_center":"CC-TW-1","salary":60000.0},"on_drift":"skip"}
{"kind":"record","table":"employees","data":{"employee_no":"E-1002","full_name":"Jun Lin","employee":"$user:lin.manager","department_id":"11111111-1111-4111-8111-111111111111","region":"TW","level":"Lead","cost_center":"CC-TW-1","salary":92000.0},"on_drift":"skip"}
{"kind":"record","table":"roster","data":{"seat_no":"S-01","employee":"$user:mei.wang","department_id":"11111111-1111-4111-8111-111111111111","region":"TW","level":"Senior","open_seat":false},"on_drift":"update"}其中大部分是管線。真正承載 HR 政策的是這幾行:
column employees.employee是user欄位,也是三種 principal 欄位型別之一:寫入時是一串原始 user id,讀取時回傳解析後的{id, name, username, is_deleted}物件,這正是$me能拿來跟資料列比對的基礎。今天新寫的文件會改用帶標籤的principal型別,額外換到「$me也能比對到檢視者所屬聊天室」這件事。column employees.manager是第二個user欄位,它的存在是為了讓簽核者寫在資料列上,而不是散落在另一份設定裡。column employees.department_id刻意用string。$me.department只能用在string、text與principal欄位;在string/text欄位上它解析成呼叫者的部門 UUID,所以 cell 必須裝 UUID,裝團隊名稱的欄位會比對不到任何資料列。(在principal欄位上同一個 token 的意思完全不同:它是該部門成員與房間構成的 SET——見 row policy token。)rule employees.manager_is_not_self是兩個user欄位之間的compare規則,也是 principal 型別唯一接受的比較方式 — 對同一種 principal 型別的另一個欄位做eq/neq(principal、user、social_client是三個各自獨立的可比較類別)。rule employees.compensation_needs_approval把任何帶著 Salary 的寫入變成待審異動:寫入者會收到帶process_id的409 approval_required,那是「已送審」,不是要自動重試的錯誤。$dept:ACME Taiwan對 employees 的grant是自助切片 —read_filter把employee欄位跟$me比對,因此每位成員只看得到自己那一列,而visible_columns沒有放入salary、annual_salary與ytd_gross。$user:lin.manager對 employees 的grant是部門切片 —$me.department會在每次請求時解析成該主管自己的部門 UUID,所以同一行複製給幾位主管都成立。$user:hr.specialist對 employees 的grant是台灣區切片,visible_columns允許清單不含薪酬,這也是Annual Salary會回傳null、而不是換個形式洩漏 Salary 的原因。$dept:ACME Taiwan對 roster 的grant讓不在該聊天室裡的部門成員也讀得到編制,這正是聊天室 scope 的資料表要收一個部門 grant 的理由。- 三行
record帶的是$user:身分 token 而不是原始 id,這份文件才能搬到另一套安裝;Employees 上用on_drift: "skip",所以重新套用永遠不會撞到簽核閘。
Apply 之前
那份文件裡的核准閘門引用的是覆核範本的原始 id,而 IaC 不能建立範本。請先在覆核模組建立覆核群組與範本,再把回傳的 id 換進 require_approval 那一行。呼叫順序見用 REST 補完。
user 儲存格是用 $user:<username> 代號種入的,所以跨環境不用改。管理者則不行:如果你希望這份文件可攜,就把 moderators 從 table 那一行拿掉,改用 REST 加。
產品流程
1. 在部門建立員工名冊
使用 tables.create 一次建立純量、選單與公式欄位:
POST /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables
Authorization: Bearer $TOKEN
Content-Type: application/json
{
"name": "ACME Employees",
"description": "HR-owned employee register",
"schema_definition": {
"columns": [
{ "name": "Name", "type": "string", "required": true },
{ "name": "Region", "type": "string" },
{ "name": "Salary", "type": "float" },
{ "name": "Level", "type": "select", "options": ["Junior", "Senior", "Lead"] },
{ "name": "Cost Center", "type": "string" },
{ "name": "Annual Salary", "type": "formula", "expression": "[Salary] * 12" }
]
}
}保留回應中的資料表 id 與 settings.column_mapping;後面的篩選條件、欄位可見清單與更新都要使用 col_<hex> 內部 key。
2. 寫入第一位員工
建立資料列時仍可使用顯示名稱。這是 records.create 的實際 body:
POST /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records
{
"data": {
"Name": "Mei Wang",
"Region": "TW",
"Salary": 60000.0,
"Level": "Senior",
"Cost Center": "CC-TW-1"
},
"created_by_ai": false
}讀回時會多出 Annual Salary: 720000.0;公式欄位不需要、也不接受人工寫值。
3. 設定資料列與欄位界線
先把同部門成員的預設讀取關閉,再用 permissions.grant 給 HR 專員一個台灣區切片。visible_columns 是 allowlist,因此刻意不放入 Salary:
PATCH /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/default-permissions
{ "can_read": "none", "can_insert": false, "can_edit": "none" }POST /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/permissions
{
"user_id": "55555555-5555-4555-8555-555555555555",
"can_read": "filtered",
"can_insert": false,
"can_edit": "filtered",
"read_filter": {
"and": [
{ "column": "col_33333333_3333_4333_8333_333333333333", "op": "eq", "value": "TW" }
]
},
"edit_filter": {
"and": [
{ "column": "col_33333333_3333_4333_8333_333333333333", "op": "eq", "value": "TW" }
]
},
"visible_columns": [
"col_44444444_4444_4444_8444_444444444444",
"col_33333333_3333_4333_8333_333333333333",
"col_66666666_6666_4666_8666_666666666666",
"col_77777777_7777_4777_8777_777777777777"
]
}若薪資對所有非主管都屬敏感資料,再以 columnAcl.update 加上資料表層級的 hide-map:
{
"column_acl": {
"col_88888888_8888_4888_8888_888888888888": { "read": "managers" }
}
}4. 以使用者身分讀取有效切片
先用 permissions.me 顯示合併後的有效權限,再呼叫 records.list:
GET /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/permissions/me
GET /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records?limit=50使用者會看到什麼
部門主管讀到完整名冊、薪資與年薪;HR 專員只會看到 Region=TW 的資料列,回應中沒有 Salary,Annual Salary 也會是 null。其他部門成員沒有適用的 grant,因此不會進入這張部門資料表。前端可以直接依回應中的欄位呈現,不需要自行再做一套遮罩邏輯。
變化與下一步
- 要把一整個部門授權給聊天室 scope 的名冊,閱讀權限矩陣與grant/insight 指南。
- 要理解
filtered、own與 filter tree,閱讀查詢資料列與有效權限。 - 要設計薪資衍生欄位,閱讀公式欄位與欄位可見性。
試試看
先在 API Playground 依序送出 tables.create、records.create、permissions.grant 與 permissions.me;若要把多張 HR 表版本化,再把模型搬到 IaC 工作台。