本文同步發表於個人部落格:一個 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 送到不同伺服器得到不同結果。有的不支援某個資源,有的資源型別名稱對不上,有的乾脆整串退回。
要能一台一台調,得先看懂那串字在說什麼。那串字分成三個部分,一個一個看。
先看等一下跟著做會用到的那一個:

三個部分各自獨立,組合起來就是一句話。以「這位病人」的身分,對 Observation 這種資料,做讀取與搜尋。
順帶一提,你在別人的程式碼裡會很常看到 patient/Patient.rs。前面小寫的 patient 是前綴,後面大寫的 Patient 是 FHIR 資源型別。兩者是不同層次的東西,卻只差一個大小寫。第一次看到很容易以為誰打錯了,所以這篇的範例刻意避開它。
launch/patient、openid、fhirUser、offline_access 這幾個長得不一樣,它們不是資源型的 scope,明天再來講。今天只看斜線後面還帶「.」的那種。launch/patient 雖然有斜線,但後面沒有「.」,所以不算。
三個前綴,差別在這張 token 代表誰。選了哪一個身分,就只拿得到那個身分該看的資料。不能拿著 patient/ 的 token 去要 user/ 才看得到的東西。要換身分只能整趟授權重跑,而且得由那個身分的人來同意。
| 前綴 | 拿到的範圍 | 誰在授權 | 典型場景 |
|---|---|---|---|
patient/ |
只有目前 context 裡那一位病人的資料 | 病人本人 | 病人自己的健康管理 app |
user/ |
登入的那個使用者看得到的全部資料 | 登入的醫護人員 | 醫師在診間用的臨床工具 |
system/ |
整台伺服器,沒有使用者 | 沒有人,用事先發好的憑證 | 夜間批次、資料同步 |
patient/Observation.rs 和 user/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 資源型別的名字,Patient、Observation、Condition、MedicationRequest,大小寫要跟 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 的 capabilities 裡 permission-v1 和 permission-v2 都有,所以兩種寫法它都收。你要送哪一種,答案在 day06 抓的那份 discovery 裡。
權限當然是越小越好,但不是憑感覺少要幾項。看畫面上要顯示什麼,就要哪些權限。
假設你要做一個顯示血壓趨勢圖的頁面:
launch/patient
Patient,只讀單筆 → patient/Patient.r
Observation,要用條件搜尋 → patient/Observation.rs
c、u、d
*
結果是 launch/patient patient/Patient.r patient/Observation.rs,三項。
第 1 項的 launch/patient 不是資源型的 scope,明天才會講。這裡先放進來,是因為少了它就拿不到 patient id。沒有 patient id,第 3 項那句搜尋就無從搜起。
多要的成本不只是安全。真實 EHR 的審核流程會逐項問你為什麼要,你要不出理由的項目會被砍掉。而砍掉之後你的 app 可能整個跑不動,得回頭重談。一開始就只要用得到的,上線會順很多。
起點:day09 之後的 smart-app,能自己走完一次授權。
產出:auth.js 的 SCOPE 從 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 重跑,停在同一頁再看一次。兩次的畫面像這樣:

這一步就是最小權限的意義:你多要一個字母,使用者的畫面上就多一行要他點頭的東西。
按 Approve 走完,看 console 印出的 scope:
送出去:launch/patient patient/Observation.cruds
回來的:launch/patient patient/Observation.cruds
一字不差。 我試了八種組合,包括 v1 的 .read、scopes_supported 沒列的 user/、甚至一個根本不存在的資源型別 patient/NotARealResource.rs。這台全部原封不動回傳。
真實的 EHR 不是這樣。它會把你要而它不給的項目拿掉,回傳縮減後的那一串。所以你的程式必須讀回傳的 scope,不能假設等於送出去的。不然你會拿著一張沒有寫入權限的 token 去寫,然後在 400 的時候一頭霧水。
預期結果:同意畫面的行數隨你改的字母增減。而 token response 的 scope 在這台永遠原封不動回傳。
必須據實說:這台在資源端完全不檢查 scope。下面這是我另外跑的一次,不是接在上面那幾步後面。拿一張只有 patient/Patient.rs 的 token 去試四種請求:

最後那一列的 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/patient、openid、fhirUser、offline_access。它們也在同意畫面上佔了行數,而且解鎖的東西跟資源沒有關係。
我們明天來講,順便整理一張表,把常見的 scope 組合列出來。