Skip to Content
實戰案例HR:人力與薪酬

HR:部門人力與薪酬名冊

情境

ACME 的人資主管要把分散的員工、薪酬與編制試算表搬進 TeamSync。公司負責人需要看全公司資料;各部門主管只能管理自己的部門;HR 專員可以看台灣區員工,但不應取得薪資。這個設計把資料掛在部門 scope,讓「哪個部門擁有資料」與「某位使用者能看哪些資料列、欄位」成為兩個清楚的控制層。

資料模型

  • Employees(部門)
    • Name:stringRegion:stringSalary:float
    • Level:select(Junior / Senior / Lead)與 Cost Center:string
    • Annual Salary:formula = [Salary] * 12
  • Payroll Register(部門)
    • NameRegionSalary
    • default_permissions 決定同部門成員的基線權限
  • Headcount Roster(聊天室)
    • NameRegionSalary
    • 可依部門授權給聊天室外的部門成員
部門主管 ──完整管理──▶ 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 TaiwanFinancelin.managerhr.specialistmei.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.employeeuser 欄位,也是三種 principal 欄位型別之一:寫入時是一串原始 user id,讀取時回傳解析後的 {id, name, username, is_deleted} 物件,這正是 $me 能拿來跟資料列比對的基礎。今天新寫的文件會改用帶標籤的 principal 型別,額外換到「$me 也能比對到檢視者所屬聊天室」這件事。
  • column employees.manager 是第二個 user 欄位,它的存在是為了讓簽核者寫在資料列上,而不是散落在另一份設定裡。
  • column employees.department_id 刻意用 string$me.department 只能用在 stringtextprincipal 欄位;在 stringtext 欄位上它解析成呼叫者的部門 UUID,所以 cell 必須裝 UUID,裝團隊名稱的欄位會比對不到任何資料列。(在 principal 欄位上同一個 token 的意思完全不同:它是該部門成員與房間構成的 SET——見 row policy token。)
  • rule employees.manager_is_not_self 是兩個 user 欄位之間的 compare 規則,也是 principal 型別唯一接受的比較方式 — 對同一種 principal 型別的另一個欄位做 eqneqprincipalusersocial_client 是三個各自獨立的可比較類別)。
  • rule employees.compensation_needs_approval 把任何帶著 Salary 的寫入變成待審異動:寫入者會收到帶 process_id409 approval_required,那是「已送審」,不是要自動重試的錯誤。
  • $dept:ACME Taiwan 對 employees 的 grant 是自助切片 — read_filteremployee 欄位跟 $me 比對,因此每位成員只看得到自己那一列,而 visible_columns 沒有放入 salaryannual_salaryytd_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" } ] } }

保留回應中的資料表 idsettings.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 的資料列,回應中沒有 SalaryAnnual Salary 也會是 null。其他部門成員沒有適用的 grant,因此不會進入這張部門資料表。前端可以直接依回應中的欄位呈現,不需要自行再做一套遮罩邏輯。

變化與下一步

試試看

先在 API Playground 依序送出 tables.createrecords.createpermissions.grantpermissions.me;若要把多張 HR 表版本化,再把模型搬到 IaC 工作台

Last updated on