iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

SMART on FHIR 開發之路:30 天做一個跨醫院的 app系列 第 10

Day10 - 一個 scope 分成三個部分

  • 分享至 

  • xImage
  •  

本文同步發表於個人部落格:一個 scope 分成三個部分


火線超人最早的 scope 是一份寫死的清單:

# Default scopes for LINE Bot users
DEFAULT_SCOPES = %w[
  patient/Patient.read
  patient/Observation.read
  patient/Condition.read
  patient/MedicationStatement.read
  patient/Encounter.read
  launch/patient
  openid
  fhirUser
].join(' ').freeze

整套系統就這一份,所有使用者共用。接第二家醫院之前,這樣完全沒問題。

後來 FhirConfiguration 上多了一個 scopes_default 欄位,取用時變成三層:

# Resolve scopes with fallback: explicit > per-server > DEFAULT_SCOPES
def resolve_scopes(explicit_scope, credentials)
  return explicit_scope if explicit_scope.present?
  return credentials[:scopes_default] if credentials[:scopes_default].present?

  DEFAULT_SCOPES
end

呼叫端指定的優先。沒指定就用那台 FHIR 伺服器自己的設定,再沒有才落到那份預設清單。而且 scopes_default 是資料表欄位,不是設定檔。代表它得能在不動程式碼、不重新部署的情況下改掉。

會演變成這樣,通常是因為同一串 scope 送到不同伺服器得到不同結果。有的不支援某個資源,有的資源型別名稱對不上,有的乾脆整串退回。

要能一台一台調,得先看懂那串字在說什麼。那串字分成三個部分,一個一個看。

一個 scope 就是三個部分

先看等一下跟著做會用到的那一個:

scope 語法拆解圖,標題「同意畫面看得到兩段,看不到前綴」,副標「操作多一個字母就多一行,前綴換掉畫面完全不變」。上半部把 patient/Observation.rs 拆成三個並排的色塊:左邊淺藍底的 patient/ 標為前綴、代表誰;中間深藍底白字的 Observation 標為資源、碰哪一種;右邊珊瑚色底白字的 .rs 標為操作、能做什麼。前綴那一段下方是一個深紅色圓形叉號,旁邊紅字寫著「使用者看不到這一段」,線到此為止。資源與操作兩段各有一條往下的直線,分別是深藍色與珊瑚色,接到下方一張白色卡片。卡片標題是「Authorize App Launch,使用者實際看到的」,兩列內容:第一列左邊一個珊瑚色的小方塊寫著 r,右邊是 Read Observation records,其中 Observation 以深藍色標示;第二列左邊珊瑚色方塊寫著 s,右邊是 Search for Observation records,Observation 同樣以深藍色標示。圖片下方兩行註記:操作那一段換成 .cruds 就多三行 Create、Update、Delete,多要一個字母,使用者就多一行要點頭的東西;前綴換成 user/ 這個畫面一模一樣,能碰到的資料卻從一位病人變成整批,這一段沒有人替你把關

三個部分各自獨立,組合起來就是一句話。以「這位病人」的身分,對 Observation 這種資料,做讀取與搜尋

順帶一提,你在別人的程式碼裡會很常看到 patient/Patient.rs。前面小寫的 patient 是前綴,後面大寫的 Patient 是 FHIR 資源型別。兩者是不同層次的東西,卻只差一個大小寫。第一次看到很容易以為誰打錯了,所以這篇的範例刻意避開它。

launch/patientopenidfhirUseroffline_access 這幾個長得不一樣,它們不是資源型的 scope,明天再來講。今天只看斜線後面還帶「.」的那種。launch/patient 雖然有斜線,但後面沒有「.」,所以不算。

前綴決定以誰的身分

三個前綴,差別在這張 token 代表誰。選了哪一個身分,就只拿得到那個身分該看的資料。不能拿著 patient/ 的 token 去要 user/ 才看得到的東西。要換身分只能整趟授權重跑,而且得由那個身分的人來同意。

前綴 拿到的範圍 誰在授權 典型場景
patient/ 只有目前 context 裡那一位病人的資料 病人本人 病人自己的健康管理 app
user/ 登入的那個使用者看得到的全部資料 登入的醫護人員 醫師在診間用的臨床工具
system/ 整台伺服器,沒有使用者 沒有人,用事先發好的憑證 夜間批次、資料同步

patient/Observation.rsuser/Observation.rs 寫法只差一個字,能碰到的資料量差了好幾個等級。前者是一個人的檢驗值,後者是那位醫師權限所及的所有病人。

system/ 那條路連授權碼流程都不走。它用的是 OAuth 的 client_credentials。沒有使用者、沒有跳轉、沒有同意畫面,只有兩台伺服器對接。

不過認證的方式要注意。SMART 的 Backend Services 規範不收密碼。它要的是一段用自己的私鑰簽出來的 JWT。火線超人接的那台收的是 client_secret

body: {
  grant_type: "client_credentials",
  client_id: fhir_configuration.client_id,
  client_secret: fhir_configuration.client_secret,
  scope: fhir_configuration.scopes_default
}

密碼和私鑰一樣藏不住。這也解釋了為什麼純前端的 app 不可能用 system/。不管是 secret 還是私鑰,你都沒有地方放。規範原文在 SMART Backend Services

本系列不走這條,但你要知道它存在。不然看到別人的 scope 開頭是 system/ 會以為自己少設定了什麼。

資源決定碰哪一種資料

中間那段就是 FHIR 資源型別的名字,PatientObservationConditionMedicationRequest,大小寫要跟 FHIR 規範一致。

也可以寫 *,代表這個前綴底下的所有資源:

patient/*.rs             這位病人的所有資料,都可以讀和搜尋
patient/Observation.rs   只有 Observation 這一種

day09 給你的那串用的是 patient/*.rs,因為當時的重點是把授權跑通。先不要在 scope 上卡住。等一下的跟著做會把 patient/*.rs 縮掉。

操作決定能做什麼

這裡有個歷史包袱。SMART v1 和 v2 的操作語法不一樣,而且你兩種都會遇到。

v1 v2
.read .r
.write .c.u.d 分開
全部 .* .cruds
搜尋 併在 .read 獨立的 .s

v2 把五種操作拆成單一字母,可以任意組合:

.r      只讀單筆
.rs     讀單筆加搜尋,這是最常見的組合
.cruds  五種全開
.cud    只能寫不能讀,聽起來怪,但寫入用的服務帳號常常正是這樣

字母請照 cruds 的順序寫。這不是排版潔癖,是為了跟規範文件與別人的程式碼對得起來。這台 sandbox 就算你倒著寫成 .sr 也收,但它連 patient/NotARealResource.rs 這種不存在的資源都照收。所以它收不代表標準允許,別拿它當依據。

把搜尋從讀取裡拆出來,是 v2 改得最關鍵的一處。GET Observation/123 是讀一筆你已經知道 id 的資料;GET Observation?patient=x&code=y 是在整個資料庫裡撈。後者洩漏的資訊多得多,v1 把兩者綁在同一個 .read 裡,分得不夠細。

火線超人那份清單用的是 v1 的 .read,因為它接的伺服器當時只吃 v1。這台 sandbox 的 capabilitiespermission-v1permission-v2 都有,所以兩種寫法它都收。你要送哪一種,答案在 day06 抓的那份 discovery 裡。

最小權限不是用猜的,是算出來的

權限當然是越小越好,但不是憑感覺少要幾項。看畫面上要顯示什麼,就要哪些權限

假設你要做一個顯示血壓趨勢圖的頁面:

  1. 要知道現在是哪一位病人 → launch/patient
  2. 畫面上要顯示病人姓名 → Patient,只讀單筆 → patient/Patient.r
  3. 要撈出這位病人所有血壓紀錄 → Observation,要用條件搜尋 → patient/Observation.rs
  4. 沒有任何寫入 → 不要 cud
  5. 不需要其他資源 → 不要 *

結果是 launch/patient patient/Patient.r patient/Observation.rs,三項。

第 1 項的 launch/patient 不是資源型的 scope,明天才會講。這裡先放進來,是因為少了它就拿不到 patient id。沒有 patient id,第 3 項那句搜尋就無從搜起。

多要的成本不只是安全。真實 EHR 的審核流程會逐項問你為什麼要,你要不出理由的項目會被砍掉。而砍掉之後你的 app 可能整個跑不動,得回頭重談。一開始就只要用得到的,上線會順很多。

跟著做:把 scope 縮到最小,看同意畫面怎麼變

起點:day09 之後的 smart-app,能自己走完一次授權。

產出auth.jsSCOPE 從 day09 那串縮到只剩兩項。而且你會知道那串字的每一段對應到同意畫面的哪一行。第三步那個 .cruds 只是拿來對照,看完改回 patient/Observation.rs 收尾。day11 從這個值繼續。

第一步,改一個常數

auth.js 頂端那一行,改成只要 Observation 的讀取與搜尋:

// day09 的值:'launch/patient patient/*.rs openid fhirUser offline_access'
export const SCOPE = 'launch/patient patient/Observation.rs'

其他檔案都不用動。scope 就只是 authorize URL 上的一個參數。

跟上面反推出來的三項比,這裡少了 patient/Patient.r。因為這次不顯示病人姓名,只看同意畫面怎麼變,所以 Patient 那項也不要。

day09 那串後面的 openid fhirUser offline_access 也一起拿掉了。day09 的 app.js 只印 token 回應的四個欄位。它沒有用到 id_token 或 refresh token,拿掉不會壞。它們各自解鎖什麼是 day11 的題目。

第二步,跑一次,停在同意畫面

按按鈕、登入。停在 Authorize App Launch 那一頁先不要按 Approve。把上面列的權限抄下來。

第三步,換成五種操作全開再看一次

SCOPE 的操作那一段改成 patient/Observation.cruds 重跑,停在同一頁再看一次。兩次的畫面像這樣:

執行結果圖,淺灰綠底色,標題「.rs 與 .cruds 跑出來的樣子」,副標「同一支 app,只換 scope 最後那幾個字母,同意畫面重跑一次」。畫面左右各一個白色圓角面板,兩個面板左上角都有三個灰色小圓點代表瀏覽器視窗,面板內是同意畫面列出的權限行。左面板上方的等寬字標籤是灰色的 launch/patient 接黑色粗體的 patient/Observation.rs,面板內三行,每行前面一個灰色小圓點,由上而下是 Read all data about the selected patient、Read Observation records、Search for Observation records,三行以下整片留白。右面板上方的標籤是灰色的 launch/patient 接黑色粗體的 patient/Observation.cruds,面板內六行,前三行與左面板逐字相同,後三行改成淺珊瑚底、左緣一條珊瑚色直條、深紅色粗體字,依序是 Create new Observation records、Update existing Observation records、Delete Observation records。兩個面板中間有一個珊瑚色圓形標記寫著 +3,垂直位置對齊右面板多出來的那三行。圖片下方三條綠色圓形編號註記:一,第一行是 launch/patient 帶出來的,跟 Observation 那一段無關;二,右邊多出來的三行,就是 c、u、d 三個字母各自的那一行;三,顯示順序固定為 Read、Search、Create、Update、Delete,與 cruds 的字母順序無關

這一步就是最小權限的意義:你多要一個字母,使用者的畫面上就多一行要他點頭的東西。

第四步,比對 token response 回傳的 scope

按 Approve 走完,看 console 印出的 scope

送出去:launch/patient patient/Observation.cruds
回來的:launch/patient patient/Observation.cruds

一字不差。 我試了八種組合,包括 v1 的 .readscopes_supported 沒列的 user/、甚至一個根本不存在的資源型別 patient/NotARealResource.rs。這台全部原封不動回傳。

真實的 EHR 不是這樣。它會把你要而它不給的項目拿掉,回傳縮減後的那一串。所以你的程式必須讀回傳的 scope,不能假設等於送出去的。不然你會拿著一張沒有寫入權限的 token 去寫,然後在 400 的時候一頭霧水。

預期結果:同意畫面的行數隨你改的字母增減。而 token response 的 scope 在這台永遠原封不動回傳。

這台 sandbox 根本不擋

必須據實說:這台在資源端完全不檢查 scope。下面這是我另外跑的一次,不是接在上面那幾步後面。拿一張只有 patient/Patient.rs 的 token 去試四種請求:

關係圖,米色底,標題「四個請求,沒有一個被 scope 擋下」,副標「同一張 token,資源端實跑四次」。上方一條深藍色橫帶,左邊是白色小字「這張 token 的 scope」,右邊是等寬字的白色大字 patient/Patient.rs。橫帶下方四張白色卡片由上而下排列,每張卡片左邊是等寬字的請求、中間是灰色小字的 scope 註記、右邊是狀態碼徽章;一條珊瑚色虛線從上到下貫穿四張卡片,虛線上方標著「scope 的邊界」,每張卡片各有一條由左往右的箭頭穿過這條虛線指向自己的狀態碼,四條箭頭互不交會。第一張卡片的請求是 GET Patient 加上病人 id,註記 scope 內,狀態碼 200;第二張是 GET Observation?patient= 加上病人 id,註記 scope 外,狀態碼 200;第三張是 GET Practitioner?_count=1,註記 scope 外,狀態碼 200;這三張的箭頭是深藍色,狀態碼徽章是淺灰藍底配深藍字。第四張卡片比前三張高,左緣有一條珊瑚色直條,請求是 POST Patient,註記 scope 沒有 c,箭頭是加粗的珊瑚色,狀態碼徽章是珊瑚底白字的 201 Created。圖片下方一行灰字:真實 EHR 在授權端與資源端都會擋。這台只有同意畫面那一關看得出 scope 換過

最後那一列的 201 Created 不是打錯。那張 token 的操作只有 .rs,一樣建得出一筆新資料。

所以這一篇的跟著做才不是「試試看讀 scope 外的東西會不會被擋」,因為不會。你能觀察到的只有同意畫面與那串回傳值。真實 EHR 兩邊都會擋,你不能把這裡的寬鬆當成 SMART 的行為。

順帶一提,我沒有把 POST 寫成上面的步驟。因為那是一台大家共用的公開 sandbox,幾千個讀者各建一筆測試資料進去。下一個人就很難用了。

同意畫面看不出前綴的差別

還有一件事。我另外拿 Patient 跑了兩組 scope,一組 patient/ 開頭,一組 user/ 開頭:

scope: launch/patient patient/Patient.rs

Read all data about the selected patient
Read Patient records
Search for Patient records
scope: user/Patient.rs user/Practitioner.r

Read Patient records
Read Practitioner records
Search for Patient records

兩邊 Patient 的那兩行措辭一模一樣。都是 Read Patient records。Search 那一行也一字不差。差別只在上面那組多了第一行,那是 launch/patient 帶出來的,跟前綴無關。至於 user/Practitioner.r 只有 Read 沒有 Search,是因為它的操作那一段只有 r

這個 app 要的是「我這一位病人的資料」,還是「這個帳號看得到的所有病人」?使用者從畫面上分不出來。

前綴的差別完全存在於協定層,這台的同意畫面沒有表達出來。真實 EHR 的畫面通常會寫清楚,但你不能指望它替你把關。user/ 還是 patient/,是你自己要負責的決定。

小結

一個資源型的 scope 就三個部分。前綴決定以誰的身分、資源決定碰哪一種資料、操作決定能做什麼。

前綴是三個部分裡最容易寫錯也最貴的。patient/user/ 差一個字,能碰到的資料卻從一位病人變成那個帳號看得到的全部。而同意畫面不一定看得出來。

操作那一段記得 v2 把搜尋從讀取裡拆出來了。.rs 是最常見的組合,.read 是 v1 的寫法,兩種你都會遇到。該用哪種去問 discovery。

火線超人從一份寫死的清單走到每台伺服器一個欄位。中間隔的就是「同一串送出去,不是每台都給你一樣的東西」。它現在還是只用 patient/,因為它的使用者永遠是病人本人。

不過今天一直被我跳過的,是那幾個沒有帶「.」的 scope:launch/patientopenidfhirUseroffline_access。它們也在同意畫面上佔了行數,而且解鎖的東西跟資源沒有關係。

我們明天來講,順便整理一張表,把常見的 scope 組合列出來。


上一篇
Day09 - 從頭跑完一次授權
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言