@o3co/create-auth-policy-verifier 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +27 -7
- package/README.md +27 -7
- package/dist/cli.mjs +0 -0
- package/dist/index.d.mts +34 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +110 -12
- package/package.json +40 -30
- package/templates/standalone/Dockerfile +75 -11
- package/templates/standalone/README.ja.md +245 -12
- package/templates/standalone/README.md +248 -12
- package/templates/standalone/config/application.conf +300 -4
- package/templates/standalone/config/development.conf +8 -0
- package/templates/standalone/config/production.conf +9 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/package.json +11 -9
- package/templates/standalone/src/__tests__/configPath.test.mts +37 -0
- package/templates/standalone/src/__tests__/loadConfig.test.mts +414 -0
- package/templates/standalone/src/__tests__/logger.test.mts +56 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +254 -48
- package/templates/standalone/src/configPath.ts +21 -0
- package/templates/standalone/src/loadConfig.ts +22 -0
- package/templates/standalone/src/logger.ts +33 -0
- package/templates/standalone/src/main.mts +11 -14
- package/templates/standalone/tsconfig.json +2 -1
- package/templates/standalone/tsconfig.typecheck.json +8 -0
- package/templates/versions.json +3 -3
- package/dist/__tests__/index.test.d.mts +0 -2
- package/dist/__tests__/index.test.d.mts.map +0 -1
- package/dist/__tests__/index.test.mjs +0 -213
|
@@ -6,36 +6,219 @@ auth.policy-verifier のデプロイ可能なサーバーテンプレートで
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
pnpm install
|
|
9
|
-
OAUTH_JWT_SECRET
|
|
9
|
+
OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
|
|
10
|
+
OAUTH_JWT_ISSUER=https://issuer.example.com \
|
|
11
|
+
OAUTH_JWT_AUDIENCE=https://api.example.com \
|
|
12
|
+
pnpm run start
|
|
10
13
|
```
|
|
11
14
|
|
|
15
|
+
`OAUTH_JWT_SECRET` はデコード後の長さで 32 バイト(256 ビット)以上を要求し、下回る値は起動時に拒否されます。[HS256 シークレットの強度](#hs256-シークレットの強度)を参照。
|
|
16
|
+
|
|
12
17
|
## 設定
|
|
13
18
|
|
|
14
|
-
設定は
|
|
19
|
+
設定は HOCON 形式で、次の順序で読み込まれます。
|
|
20
|
+
|
|
21
|
+
1. `config/application.conf` — ベース設定
|
|
22
|
+
2. `config/{ENV}.conf` — 現在の環境の overlay
|
|
23
|
+
|
|
24
|
+
`ENV = CONFIG_ENV || NODE_ENV || "development"` で決まります。overlay の値は
|
|
25
|
+
`application.conf` を上書きします。scaffold には `development.conf` と
|
|
26
|
+
`production.conf` が同梱されています。別の環境を追加する場合は
|
|
27
|
+
`config/staging.conf` のようにファイルを追加し、`CONFIG_ENV=staging` を設定してください。
|
|
28
|
+
`{ENV}.conf` が存在しない場合は起動時エラーになります。
|
|
29
|
+
|
|
30
|
+
個々の値は環境変数でも上書きできます。
|
|
15
31
|
|
|
16
32
|
| 変数 | デフォルト | 説明 |
|
|
17
33
|
|---|---|---|
|
|
18
|
-
| `HTTP_HOSTNAME` | `0.0.0.0`
|
|
34
|
+
| `HTTP_HOSTNAME` | `127.0.0.1` | バインドするホスト名。既定はループバック — [信頼境界](#信頼境界)を参照。コンテナでは `0.0.0.0` の指定が必要 |
|
|
19
35
|
| `HTTP_PORT` | `3000` | バインドするポート番号 |
|
|
20
36
|
| `HTTP_PATH_PREFIX` | `""` | URL パスプレフィックス |
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
37
|
+
| `HTTP_CALLER_AUTH_TOKEN` | (未設定) | 呼び出し元サービスが提示する共有資格情報。未設定の場合、ポートに到達できる任意の相手が判定を要求できる |
|
|
38
|
+
| `HTTP_CALLER_AUTH_HEADER` | `x-caller-token` | その資格情報を載せるヘッダ |
|
|
39
|
+
| `OAUTH_JWT_SECRET` | (必須) | HMAC-HS256 JWT 署名シークレット。デコード後 32 バイト以上 — [HS256 シークレットの強度](#hs256-シークレットの強度)を参照 |
|
|
40
|
+
| `OAUTH_JWT_ISSUER` | (必須) | この deployment が受け入れる issuer — RFC 9068 §4 `iss` |
|
|
41
|
+
| `OAUTH_JWT_AUDIENCE` | (必須) | この resource server を指す audience — RFC 9068 §4 `aud` |
|
|
42
|
+
| `OAUTH_JWT_JWKS_URI` | — | RS256/ES256/EdDSA の JWKS エンドポイント。`https://` 必須。平文 `http://` はループバックホスト(`localhost`, `127.0.0.0/8`, `[::1]`)のみ許可 |
|
|
43
|
+
| `OAUTH_JWT_JWKS_TIMEOUT_MS` | `5000` | JWKS 取得をこの時間で打ち切る |
|
|
44
|
+
| `OAUTH_JWT_JWKS_COOLDOWN_MS` | `30000` | JWKS 再取得の最小間隔 |
|
|
45
|
+
| `OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS` | `600000` | 取得した JWKS をキャッシュから返す期間 |
|
|
46
|
+
| `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | 受け入れる `typ` ヘッダ。同じ鍵で署名された id/refresh/logout token を拒否する |
|
|
47
|
+
| `OAUTH_JWT_MODE` | `verify` | `verify` はトークンを完全検証。明示的な `insecure-decode`(テスト専用)は署名検証なしでデコードする — `exp`/`nbf` は引き続き強制される |
|
|
48
|
+
| `RULE_ON_EMPTY_RULE_SET` | `deny` | ルールが 1 つも集まらなかったときの決定(`deny` \| `allow`) |
|
|
49
|
+
| `VERIFY_MAX_BATCH_SIZE` | `50` | `POST /verify/batch` の件数上限 |
|
|
50
|
+
| `LOG_LEVEL` | `info` | 出力する最低レベル: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`。decision ログのスイッチも兼ねる — [可観測性](#可観測性)を参照 |
|
|
51
|
+
|
|
52
|
+
## HS256 シークレットの強度
|
|
53
|
+
|
|
54
|
+
`OAUTH_JWT_SECRET` は鍵素材として **32 バイト(256 ビット)以上**を持つ必要があります。足りない値は config パース時に拒否されるため、推測可能な鍵で稼働し続けるのではなく起動時に失敗します。
|
|
55
|
+
|
|
56
|
+
HS256 は対称鍵です。トークンを検証する値がそのままトークンを署名する値なので、推測された場合の被害は「発行済みトークンを読める」ことではなく「任意の subject のトークンを発行できる」ことです。RFC 7518 §3.2 はハッシュ出力幅以上の鍵を要求しており、auth.provider も同じ共有値に同じ下限を課しています。
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
OAUTH_JWT_SECRET=$(openssl rand -hex 32) # 64 文字 / 32 バイト
|
|
60
|
+
OAUTH_JWT_SECRET=$(openssl rand -base64 32) # 44 文字 / 32 バイト
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
判定は**デコード後**の素材に対して、もっとも小さく読める解釈で行います。攻撃者が実際に使えるのがその解釈だからです。
|
|
64
|
+
|
|
65
|
+
- `openssl rand -hex 16` は 32 *文字* だが 16 *バイト* しかない — 拒否。
|
|
66
|
+
- ランダムな英数字 32 文字は base64 本体として読めるため 24 バイト — 拒否。1 文字あたり 62 通りは約 5.95 ビットであり、8 ビットではない。
|
|
67
|
+
- 記号を含むパスフレーズは UTF-8 長で測られるため、32 文字なら通る。
|
|
68
|
+
|
|
69
|
+
同じ下限がローテーションブロックの `previousSecrets[].secret` 全件にも適用されます — 退役したシークレットも重複期間中は検証鍵であり、現行と同じようにトークンを発行できるからです。
|
|
70
|
+
|
|
71
|
+
この検査で見えるのは長さだけです。40 文字の英文は 40 バイトと数えられますが実際の強度ははるかに低いので、値は必ずランダムに生成してください。
|
|
72
|
+
|
|
73
|
+
## 信頼境界
|
|
74
|
+
|
|
75
|
+
`/verify` は認可の判定結果を返すため、誰でも到達できるポートは decision oracle になります。subject トークンが示すのは判定の *対象* であって、`resource` / `action` / `context` を渡したのがどのサービスかではありません。
|
|
76
|
+
|
|
77
|
+
そのためテンプレートの既定 bind は **ループバック** です。これはサイドカー構成 — enforcement 層が同一ホストで動き、それ以外は接続できません。
|
|
78
|
+
|
|
79
|
+
別ホスト・別コンテナから到達させるのは明示的なオプトインで、次の 2 つが必要です。
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
HTTP_HOSTNAME=0.0.0.0 # そもそもポートに到達できるようにする
|
|
83
|
+
HTTP_CALLER_AUTH_TOKEN=<secret> # そのうえで自分の enforcement 層にだけ応答させる
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`docker-compose.yml` は既に `HTTP_HOSTNAME=0.0.0.0` を設定しています。コンテナ内でのループバックは「どこからも到達できない」ことを意味し、publish したポートが繋がらないためです。資格情報は `.env` に置いてください。
|
|
87
|
+
|
|
88
|
+
`HTTP_CALLER_AUTH_TOKEN` を設定すると、`/verify` と `/verify/batch` への全リクエストが `HTTP_CALLER_AUTH_HEADER`(既定 `x-caller-token`。subject トークンを載せる `Authorization` を避けているのは意図的)にその値をそのまま載せる必要があります。
|
|
89
|
+
|
|
90
|
+
```http
|
|
91
|
+
POST /verify HTTP/1.1
|
|
92
|
+
x-caller-token: <secret>
|
|
93
|
+
Authorization: Bearer <jwt>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
それ以外は body のパースより前に `401 { "decision": "deny", "code": "caller_unauthenticated", "message": "Caller authentication failed" }` を返します。`GET /healthcheck` は常に非ゲートなので、コンテナの healthcheck はそのまま動作します。
|
|
97
|
+
|
|
98
|
+
資格情報を未設定のままにする運用もサポートされており、ループバックであれば問題ありません。ループバック以外に bind した場合は起動時に `unauthenticated_non_loopback_bind` が warn で記録されます — 修正する価値があるのはこの組み合わせです。
|
|
99
|
+
|
|
100
|
+
`GET /metrics` も非ゲートです。理由と、ループバック以外に bind したときに何を意味するかは [`/metrics` への到達方法](#metrics-への到達方法)を参照してください。
|
|
101
|
+
|
|
102
|
+
## 可観測性
|
|
103
|
+
|
|
104
|
+
テンプレートは [pino](https://getpino.io) を使って stdout に NDJSON を出力し、`GET /metrics` で Prometheus メトリクスを提供します。どちらも既定で有効です。
|
|
105
|
+
|
|
106
|
+
### decision ログ
|
|
107
|
+
|
|
108
|
+
判定 1 件ごとに `decision` という名前の構造化イベントを `info` で 1 行出力します。
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{"level":30,"msg":"decision","requestId":"6f1c…","sub":"user-42","resource":"project:7","action":"read","decision":"deny","code":"invalid_scope","deniedBy":{"ruleType":"scope","refused":["invalid_scope"]},"durationMs":0.412}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| フィールド | 意味 |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `sub` | 提示されたトークンの JWT `sub`。トークンが持たない場合は省略される |
|
|
117
|
+
| `resource` / `action` | 呼び出し元が送ったそのままの値 |
|
|
118
|
+
| `decision` | `allow` または `deny` |
|
|
119
|
+
| `code` | deny のみ — 呼び出し元がワイヤ上で受け取ったコードと同じ |
|
|
120
|
+
| `satisfiedBy` | allow のみ — 各グループを満たしたルールの `{ruleType, code}` |
|
|
121
|
+
| `deniedBy` | deny のみ — 最初に失敗したグループと、そこで拒否した全代替ルール |
|
|
122
|
+
| `requestId` | 呼び出し元が送った `x-request-id`。無い場合は省略される |
|
|
123
|
+
| `durationMs` | Collector パイプラインと evaluator に費やした時間。HTTP の往復時間ではない |
|
|
124
|
+
|
|
125
|
+
判定 1 件につき 1 行なので、N 件の `POST /verify/batch` は同一 `requestId` を持つ N 行を出力します。アラートとインデックスはイベント名に対して張り、メッセージ本文には張らないでください。
|
|
126
|
+
|
|
127
|
+
**スイッチは `LOG_LEVEL` です。** この行は `info` なので `warn` にすればストリームごと止まります。忘れるべき 2 つ目のフラグはありません。deny は decision point にとって障害ではなく *正常な* 結果なので、`warn` には送っていません。送ってしまえば任意の呼び出し元が warn レベルのノイズを製造でき、`warn` が「何かがおかしい」を意味しなくなります。アラートはメトリクスに、「なぜ」はログに求めてください。
|
|
128
|
+
|
|
129
|
+
**意図的に載せていないもの:**
|
|
130
|
+
|
|
131
|
+
- **生の bearer トークンと、`sub` を超えるクレーム集合。** トークンはメールアドレスやグループ所属など発行者が入れた任意の情報を運びますが、判定の説明にはどれも不要です。この記録は成功リクエストを含む全リクエストで書かれ、トークンとは影響範囲の異なるアグリゲータへ送られます。
|
|
132
|
+
- **呼び出し元の `context` オブジェクト。** 自由形式で collector にそのまま渡されるため、呼び出し側サービスのリクエストペイロードが行き着く場所そのものです。これを記録すると監査ストリームがそのペイロードの複製になります。
|
|
133
|
+
- **ルールの `message` 文字列。** 既に行に載っている resource と action から導かれる内容です。
|
|
134
|
+
|
|
135
|
+
### メトリクス
|
|
136
|
+
|
|
137
|
+
`GET /metrics` は Prometheus text exposition format を返します。
|
|
138
|
+
|
|
139
|
+
| メトリクス | 種別 | ラベル | 何に答えるか |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| `auth_decisions_total` | counter | `decision` | allow/deny 比率。系列はちょうど 2 本 |
|
|
142
|
+
| `auth_denials_total` | counter | `code` | どのルールが deny しているか — ログ行の `deniedBy` の集計版 |
|
|
143
|
+
| `auth_decision_duration_seconds` | histogram | `decision` | パイプラインと evaluator に費やした時間 |
|
|
144
|
+
| `http_request_duration_seconds` | histogram | `method`, `route`, `status` | リクエストレート・エラーレート・レイテンシ(RED メソッド) |
|
|
145
|
+
| `auth_policy_verifier_*` | 各種 | — | Node プロセス既定メトリクス — event loop lag, heap, GC, handles |
|
|
146
|
+
|
|
147
|
+
`http_request_duration_seconds` は [auth.provider](https://github.com/o3co/auth.provider) と同じ名前・同じラベル集合なので、1 つの Prometheus job と 1 つのダッシュボード規約でスタックの両側を賄えます。
|
|
148
|
+
|
|
149
|
+
deny はエラーではないので、書く価値のあるアラートは deny の存在ではなく `rate(auth_decisions_total{decision="deny"}[5m]) / rate(auth_decisions_total[5m])` の *変化* に対するものです。
|
|
150
|
+
|
|
151
|
+
#### すべてのラベルは有界
|
|
152
|
+
|
|
153
|
+
有界でないラベルは値ごとに新しい時系列を作り出します。これは、監視すべき対象を監視する仕組みそのものをメトリクスエンドポイントが落とす典型的な経路です。以下のいずれも `/metrics` へのアクセスなしに到達できます。
|
|
154
|
+
|
|
155
|
+
- **`resource` と `action` はそもそもラベルにしていません。** これらはリクエストボディから直接来るもので、構造上有界ではありません(`project:1`, `project:2`, …)。代わりに decision ログ行に載せています — 高カーディナリティの事実にはそちらが適切な媒体です。`sub` も同じ理由で除外しています(ユーザーごとに 1 系列になる)。
|
|
156
|
+
- **`route`** は URL ではなく Express の route *パターン*で、マッチしなかったもの(ポートに到達できる何かからの 404 プローブ)はすべて `route="unmatched"` に潰れます。
|
|
157
|
+
- **`method`** はこのサービスが実際に処理できる 9 メソッドの allowlist で、それ以外は `method="other"` です。Node のパーサは llhttp が知る全メソッド(`PURGE`, `MKCOL`, `PROPFIND` など)をサーバーに渡すため、`req.method` はパスと同程度に呼び出し元の制御下にあります。
|
|
158
|
+
- **`code`** は deployment が設定したルールに由来するので運用者が有界にできます — ただし `code` は `Rule` インターフェースのフィールドであり、ルールはリクエストごとに構築されるため、カスタム rule collector が resource から code を導出するようになるまでは 1 回の編集です。異なる値 32 個で打ち止め、それ以降は `code="other"` に潰れます。`code="other"` が伸びていること自体が、リクエストごとに code を作っているルールがあるというシグナルです。
|
|
159
|
+
|
|
160
|
+
#### `/metrics` への到達方法
|
|
161
|
+
|
|
162
|
+
`/metrics` は `HTTP_CALLER_AUTH_TOKEN` で**ゲートしていません**。Prometheus の scrape config が持つのは `authorization` / `basic_auth` / `oauth2` であって任意ヘッダではないため、`x-caller-token` でゲートすると標準の scraper からは scrape 不能になり、その回避策は *判定* を認可する資格情報を監視システムに渡すことになってしまいます。このエンドポイントが公開するのは有界ラベル上のカウントとレイテンシだけで、subject も resource も action も、個々の判定に関する情報も含みません。
|
|
163
|
+
|
|
164
|
+
**代わりに境界となるのは bind アドレスで、既定はループバックです。** したがって scraper は同一ホスト側に置く必要があります。
|
|
165
|
+
|
|
166
|
+
- **Kubernetes** — 同一 Pod のコンテナはネットワーク名前空間を共有するので、同じ Pod 内の Prometheus サイドカー(または OTel collector)が bind を変えずに `http://127.0.0.1:3000/metrics` を scrape できます。既定をそのまま保てるのはこの形です。
|
|
167
|
+
- **docker compose** — `docker-compose.yml` は既に `HTTP_HOSTNAME=0.0.0.0` を設定しています(コンテナ内のループバックはどこからも到達できないため)。この場合ポートは compose ネットワーク上の任意のコンテナから到達可能で、`/metrics` も同様です。publish する外向きインターフェースには出さず(`ports:` ではなく `expose:`)、scraper を同じネットワークに置いてください。
|
|
168
|
+
- **それ以外のループバック以外 bind** — `/verify` 自体と同様に、ネットワーク層(NetworkPolicy、セキュリティグループ、ファイアウォール)でポートを制限してください。
|
|
169
|
+
|
|
170
|
+
サイドカー構成の scrape config 例:
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
scrape_configs:
|
|
174
|
+
- job_name: auth-policy-verifier
|
|
175
|
+
static_configs:
|
|
176
|
+
- targets: ["127.0.0.1:3000"]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`HTTP_PATH_PREFIX` は他のエンドポイントと同様にこのパスも動かします。`HTTP_PATH_PREFIX=/pdp` なら `/pdp/metrics` になり、scrape config の `metrics_path` も合わせる必要があります。
|
|
180
|
+
|
|
181
|
+
**まだ公開していないもの:** 依存先ごとの `up` ゲージ(auth.provider はバックエンドストア向けに持っています)。ここでの相当物は JWKS エンドポイントですが、サンプリング対象になる readiness probe のレジストリがありません。依存先の一覧を別途手で持つゲージは、auth.provider が probe をサンプリングすることで避けた drift そのものです。そのレジストリができるまで、JWKS 障害は `jwt_verification_unavailable` ログイベントとして見えます — アラートを張れるようにこれは `error` で出力されています。
|
|
23
182
|
|
|
24
183
|
## デフォルトコレクター
|
|
25
184
|
|
|
26
185
|
以下のコレクターが `builtinCollectorsModule` を通じて登録されます。
|
|
27
186
|
|
|
28
|
-
**Attribute collectors
|
|
187
|
+
**Attribute collectors**:
|
|
29
188
|
|
|
30
|
-
- `PayloadScopeCollector` — OAuth スコープを抽出する
|
|
31
|
-
- `PayloadSubjectIdCollector` —
|
|
189
|
+
- `PayloadScopeCollector` — 検証済みサブジェクト属性(JWT のクレーム)から OAuth スコープを抽出する
|
|
190
|
+
- `PayloadSubjectIdCollector` — 検証済みサブジェクト属性(JWT のクレーム)からサブジェクト識別子を抽出する
|
|
191
|
+
- `RequestContextAttributeCollector` — リクエストボディの `context` の宣言済みフィールドを属性に昇格させる(既定では未接続。`attribute.collectors` に `attributes` マッピングを付けて追加する)
|
|
32
192
|
|
|
33
193
|
**Rule collectors**(認可ルールを解決):
|
|
34
194
|
|
|
35
|
-
- `ResourceActionScopeRuleCollector` —
|
|
36
|
-
- `ResourceActionPermissionRuleCollector` —
|
|
195
|
+
- `ResourceActionScopeRuleCollector` — トークンがスコープ `<action>:<resourceType>` を持つことを要求する
|
|
196
|
+
- `ResourceActionPermissionRuleCollector` — パーミッション `<resource.raw>.perm:<action>` を要求する(既定では未接続。下記参照)
|
|
197
|
+
|
|
198
|
+
### 出荷時のポリシー
|
|
37
199
|
|
|
38
|
-
|
|
200
|
+
`config/application.conf` はトークンの `scope` クレームだけで認可します。つまり、
|
|
201
|
+
リクエスト対象の resource と action に対して `<action>:<resourceType>` を bearer
|
|
202
|
+
トークンが持っているときにちょうど許可されます。スコープを発行する IdP があれば
|
|
203
|
+
認可ストアを別途立てなくてもそのまま機能し、かつ fail-closed です — スコープを
|
|
204
|
+
持たないトークンや誤ったスコープは拒否され、ルールが 1 つも集まらなかった
|
|
205
|
+
リクエストも拒否されます(`rule.onEmptyRuleSet = "deny"`)。
|
|
206
|
+
|
|
207
|
+
ルールは種類ごとにグループ化され、**すべてのグループが通る必要があります**。
|
|
208
|
+
そのため、属性を供給する attribute collector なしに rule collector を有効化すると、
|
|
209
|
+
何をもってしても満たせないグループができ、トークンの内容によらず全リクエストを
|
|
210
|
+
拒否する verifier になります。`ResourceActionPermissionRuleCollector` が
|
|
211
|
+
「未設定」ではなく明示的に無効なのはこのためです — このルールは permissions/roles
|
|
212
|
+
属性を読みますが、出荷時の `attribute.collectors` はそれを生成しません。有効化する
|
|
213
|
+
ときは供給側と同じ編集でセットにしてください(デプロイ単位の固定リストなら
|
|
214
|
+
`StaticPermissionCollector`、サブジェクトごとに異なるなら独自の `AttributeCollector`)。
|
|
215
|
+
`application.conf` の該当コレクターの隣に具体例があります。
|
|
216
|
+
|
|
217
|
+
使用されるリソースパーサーは `DotNotationResourceParser` です。`segment *( "." segment )`
|
|
218
|
+
(`segment = type [ ":" id ]`)を受け付け、セグメントの type を `.` で結合して `resourceType` を導出します
|
|
219
|
+
— つまり `"project:1.member:2"` は type `project.member`、id `2` です。この文法から外れたリソース文字列
|
|
220
|
+
(`a..b` のような空セグメント、`a:1:2` のような 2 つ目の `:`、前後の空白)は `400 invalid_request` になります。
|
|
221
|
+
`resourceType` は scope ルールが認可する対象そのものなので、修復せず拒否します。
|
|
39
222
|
|
|
40
223
|
## カスタムモジュールの追加
|
|
41
224
|
|
|
@@ -68,15 +251,65 @@ const app = await createApp({
|
|
|
68
251
|
|
|
69
252
|
```sh
|
|
70
253
|
make build
|
|
71
|
-
docker run
|
|
254
|
+
docker run \
|
|
255
|
+
-p 3000:3000 \
|
|
256
|
+
-e HTTP_HOSTNAME=0.0.0.0 \
|
|
257
|
+
-e HTTP_CALLER_AUTH_TOKEN=<secret> \
|
|
258
|
+
-e OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
|
|
259
|
+
-e OAUTH_JWT_ISSUER=https://issuer.example.com \
|
|
260
|
+
-e OAUTH_JWT_AUDIENCE=https://api.example.com \
|
|
261
|
+
auth-policy-verifier
|
|
72
262
|
```
|
|
73
263
|
|
|
264
|
+
最初の 2 つは [信頼境界](#信頼境界) で述べた opt-in の組で、ポートを公開する
|
|
265
|
+
こと自体が両方を必要にします:
|
|
266
|
+
|
|
267
|
+
- `HTTP_HOSTNAME=0.0.0.0` — 設定は既定で loopback に bind しますが、コンテナ内
|
|
268
|
+
での loopback は「どこからも到達できない」を意味し、公開したポートは決して
|
|
269
|
+
繋がらないため。
|
|
270
|
+
- `HTTP_CALLER_AUTH_TOKEN` — 同じ設定によって `/verify` がポートに到達できる
|
|
271
|
+
すべてのものから届く状態になり、`/verify` は認可判断を返すため。省略して
|
|
272
|
+
よいのは、ホストの外から公開ポートに到達できない場合だけです。
|
|
273
|
+
|
|
74
274
|
Docker Compose でローカル開発する場合:
|
|
75
275
|
|
|
76
276
|
```sh
|
|
77
277
|
make dev
|
|
78
278
|
```
|
|
79
279
|
|
|
280
|
+
`docker-compose.yml` は `HTTP_HOSTNAME=0.0.0.0` を自身で設定し、任意の `.env`
|
|
281
|
+
を読み込みます。資格情報は供給**しません** — `HTTP_CALLER_AUTH_TOKEN` はその
|
|
282
|
+
`.env` に置いてください。
|
|
283
|
+
|
|
284
|
+
### `pnpm-lock.yaml` はビルド入力
|
|
285
|
+
|
|
286
|
+
同じソースからの 2 回のビルドが 2 つの異なるイメージを生まないようにするのが
|
|
287
|
+
`pnpm install --frozen-lockfile` です。そのため `Dockerfile` は
|
|
288
|
+
`pnpm-lock.yaml` を COPY し、無ければビルドは失敗します。
|
|
289
|
+
`create-auth-policy-verifier` が scaffold 時に生成するので、コミットして
|
|
290
|
+
ください。無い場合(オフラインでの scaffold、または `--no-lockfile`)は
|
|
291
|
+
`pnpm install` を一度実行してその結果をコミットします。
|
|
292
|
+
|
|
293
|
+
したがって依存更新は lockfile の変更です: `pnpm update` → commit → 再ビルド。
|
|
294
|
+
ベースイメージを digest で固定しているのも同じ理由で、更新方法も同じ —
|
|
295
|
+
tag と digest を意図的に一緒に上げます。
|
|
296
|
+
|
|
297
|
+
### HEALTHCHECK が報告するもの
|
|
298
|
+
|
|
299
|
+
コンテナの healthcheck は `127.0.0.1` ではなく、**コンテナ自身の到達可能
|
|
300
|
+
アドレス**を probe します。これは意図的です。loopback に対する probe は、
|
|
301
|
+
サーバーが loopback に bind していて誰も到達できない状態でも成功してしまい —
|
|
302
|
+
外から見れば死んでいるコンテナを「healthy」と報告します。実際に到達可能な
|
|
303
|
+
アドレスを叩けば呼び出し元と同じ問いになるため、`HTTP_HOSTNAME=0.0.0.0` の
|
|
304
|
+
付け忘れは隠れずに `unhealthy` として現れます。
|
|
305
|
+
|
|
306
|
+
`GET /healthcheck` は `http.callerAuth` で決してゲートされないので、probe に
|
|
307
|
+
資格情報は不要です。`HTTP_PATH_PREFIX` と `HTTP_PORT` には追従します。
|
|
308
|
+
|
|
309
|
+
例外は 1 つ、呼び出し元と network namespace を共有する sidecar 構成です。
|
|
310
|
+
そこでは loopback bind が正しく、到達可能アドレスでは何も listen していま
|
|
311
|
+
せん。そのサービスでは `healthcheck:` を override してください。
|
|
312
|
+
|
|
80
313
|
## 関連
|
|
81
314
|
|
|
82
315
|
- [`@o3co/auth.policy-verifier.server`](../../packages/server) — Express アプリファクトリと設定スキーマ
|
|
@@ -6,36 +6,222 @@ Deployable server template for auth.policy-verifier. This is the composition roo
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
pnpm install
|
|
9
|
-
OAUTH_JWT_SECRET
|
|
9
|
+
OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
|
|
10
|
+
OAUTH_JWT_ISSUER=https://issuer.example.com \
|
|
11
|
+
OAUTH_JWT_AUDIENCE=https://api.example.com \
|
|
12
|
+
pnpm run start
|
|
10
13
|
```
|
|
11
14
|
|
|
15
|
+
`OAUTH_JWT_SECRET` must carry at least 32 bytes (256 bits) of key material, measured on its decoded length — a shorter value is refused at boot. See [HS256 secret strength](#hs256-secret-strength).
|
|
16
|
+
|
|
12
17
|
## Configuration
|
|
13
18
|
|
|
14
|
-
Configuration is
|
|
19
|
+
Configuration is loaded in HOCON format in this order:
|
|
20
|
+
|
|
21
|
+
1. `config/application.conf` — base config
|
|
22
|
+
2. `config/{ENV}.conf` — overlay for the current environment
|
|
23
|
+
|
|
24
|
+
`ENV = CONFIG_ENV || NODE_ENV || "development"`. Values in the overlay
|
|
25
|
+
take precedence over `application.conf`. The scaffold ships with
|
|
26
|
+
`development.conf` and `production.conf`. To add another environment,
|
|
27
|
+
create a file such as `config/staging.conf` and set `CONFIG_ENV=staging`.
|
|
28
|
+
A missing `{ENV}.conf` is a boot-time error.
|
|
29
|
+
|
|
30
|
+
Individual values can also be overridden with environment variables.
|
|
15
31
|
|
|
16
32
|
| Variable | Default | Description |
|
|
17
33
|
|---|---|---|
|
|
18
|
-
| `HTTP_HOSTNAME` | `
|
|
34
|
+
| `HTTP_HOSTNAME` | `127.0.0.1` | Bind hostname. Loopback by default — see [Trust boundary](#trust-boundary). Containers must set `0.0.0.0` |
|
|
19
35
|
| `HTTP_PORT` | `3000` | Bind port |
|
|
20
36
|
| `HTTP_PATH_PREFIX` | `""` | URL path prefix |
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
37
|
+
| `HTTP_CALLER_AUTH_TOKEN` | (unset) | Shared credential the calling service must present. Unset means any caller who can reach the port may ask for decisions |
|
|
38
|
+
| `HTTP_CALLER_AUTH_HEADER` | `x-caller-token` | Header carrying that credential |
|
|
39
|
+
| `OAUTH_JWT_SECRET` | (required) | HMAC-HS256 JWT signing secret. At least 32 decoded bytes — see [HS256 secret strength](#hs256-secret-strength) |
|
|
40
|
+
| `OAUTH_JWT_ISSUER` | (required) | Issuer this deployment accepts — RFC 9068 §4 `iss` |
|
|
41
|
+
| `OAUTH_JWT_AUDIENCE` | (required) | Audience identifying this resource server — RFC 9068 §4 `aud` |
|
|
42
|
+
| `OAUTH_JWT_JWKS_URI` | — | JWKS endpoint for RS256/ES256/EdDSA. Must be `https://`; plaintext `http://` is accepted only for loopback hosts (`localhost`, `127.0.0.0/8`, `[::1]`) |
|
|
43
|
+
| `OAUTH_JWT_JWKS_TIMEOUT_MS` | `5000` | Abort a JWKS fetch after this long |
|
|
44
|
+
| `OAUTH_JWT_JWKS_COOLDOWN_MS` | `30000` | Minimum spacing between JWKS fetches |
|
|
45
|
+
| `OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS` | `600000` | How long a fetched JWKS is served from cache |
|
|
46
|
+
| `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | Accepted `typ` header; rejects id/refresh/logout tokens signed with the same key |
|
|
47
|
+
| `OAUTH_JWT_MODE` | `verify` | `verify` fully verifies tokens; the explicit `insecure-decode` (test-only) decodes without signature verification — `exp`/`nbf` are still enforced |
|
|
48
|
+
| `RULE_ON_EMPTY_RULE_SET` | `deny` | Decision when no rule is collected (`deny` \| `allow`) |
|
|
49
|
+
| `VERIFY_MAX_BATCH_SIZE` | `50` | Cap on `POST /verify/batch` entries |
|
|
50
|
+
| `VERIFY_CREDENTIAL_TO_COLLECTORS` | `never` | `expose` hands collectors the raw credential as `context.credential` — only for a collector that calls a downstream API as the subject. The default keeps collectors on verified claims; the credential is replayable and a logged context would leak it |
|
|
51
|
+
| `LOG_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Also the switch for the decision log — see [Observability](#observability) |
|
|
52
|
+
|
|
53
|
+
## HS256 secret strength
|
|
54
|
+
|
|
55
|
+
`OAUTH_JWT_SECRET` must carry at least **32 bytes (256 bits)** of key material. A shorter one is refused when the config is parsed, so the process fails at boot rather than serving with a guessable key.
|
|
56
|
+
|
|
57
|
+
HS256 is symmetric: the value that verifies a token is the value that signs one, so anyone who guesses it can mint tokens for any subject — not merely read the ones already issued. RFC 7518 §3.2 requires a key at least as wide as the hash output, and auth.provider holds the same shared value to the same floor.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
OAUTH_JWT_SECRET=$(openssl rand -hex 32) # 64 characters, 32 bytes
|
|
61
|
+
OAUTH_JWT_SECRET=$(openssl rand -base64 32) # 44 characters, 32 bytes
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The measurement is on **decoded** material at the smallest plausible reading, which is the one an attacker gets to use:
|
|
65
|
+
|
|
66
|
+
- `openssl rand -hex 16` produces 32 *characters* but only 16 *bytes* — refused.
|
|
67
|
+
- 32 random alphanumerics read as a base64 body (24 bytes) — refused, and rightly: 62 possibilities per character is ~5.95 bits, not 8.
|
|
68
|
+
- A passphrase containing punctuation is measured on its UTF-8 length, so 32 characters passes.
|
|
69
|
+
|
|
70
|
+
The same floor applies to every `previousSecrets[].secret` in the rotation block — a retired secret verifies for its whole overlap window and can mint tokens exactly as the current one can.
|
|
71
|
+
|
|
72
|
+
Length is all the check can see. A 40-character sentence measures 40 bytes and carries far less; generate the value randomly.
|
|
73
|
+
|
|
74
|
+
## Trust boundary
|
|
75
|
+
|
|
76
|
+
`/verify` answers with an authorization decision, so a port anyone can reach is a decision oracle: the subject token says who a decision is *about*, never which service supplied `resource` / `action` / `context`.
|
|
77
|
+
|
|
78
|
+
The template therefore binds **loopback** by default. That is the sidecar shape — the enforcement layer runs on the same host and nothing else can connect.
|
|
79
|
+
|
|
80
|
+
Reaching the verifier from another host or container is an explicit opt-in, and it needs two things:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
HTTP_HOSTNAME=0.0.0.0 # make the port reachable at all
|
|
84
|
+
HTTP_CALLER_AUTH_TOKEN=<secret> # and make it answer only your enforcement layer
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`docker-compose.yml` sets `HTTP_HOSTNAME=0.0.0.0` already, because inside a container loopback means "reachable from nothing" and the published port would never connect. Put the credential in `.env`.
|
|
88
|
+
|
|
89
|
+
With `HTTP_CALLER_AUTH_TOKEN` set, every request to `/verify` and `/verify/batch` must carry it verbatim in `HTTP_CALLER_AUTH_HEADER` (default `x-caller-token`, deliberately not `Authorization`, which carries the subject token):
|
|
90
|
+
|
|
91
|
+
```http
|
|
92
|
+
POST /verify HTTP/1.1
|
|
93
|
+
x-caller-token: <secret>
|
|
94
|
+
Authorization: Bearer <jwt>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Anything else gets `401 { "decision": "deny", "code": "caller_unauthenticated", "message": "Caller authentication failed" }`, decided before the body is parsed. `GET /healthcheck` is never gated, so the container healthcheck keeps working.
|
|
98
|
+
|
|
99
|
+
Leaving the credential unset is supported and is fine on loopback. On a non-loopback bind the server logs `unauthenticated_non_loopback_bind` at warn on boot — that combination is the one worth fixing.
|
|
100
|
+
|
|
101
|
+
`GET /metrics` is not gated either — see [Reaching `/metrics`](#reaching-metrics) for why, and for what that means when the bind is not loopback.
|
|
102
|
+
|
|
103
|
+
## Observability
|
|
104
|
+
|
|
105
|
+
The template logs newline-delimited JSON on stdout through [pino](https://getpino.io) and serves Prometheus metrics at `GET /metrics`. Both are on by default.
|
|
106
|
+
|
|
107
|
+
### The decision log
|
|
108
|
+
|
|
109
|
+
Every decision emits one structured event named `decision`, at `info`:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{"level":30,"msg":"decision","requestId":"6f1c…","sub":"user-42","resource":"project:7","action":"read","decision":"deny","code":"invalid_scope","deniedBy":{"ruleType":"scope","refused":["invalid_scope"]},"durationMs":0.412}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Field | Meaning |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `sub` | JWT `sub` of the presented token. Omitted when the token carries none |
|
|
118
|
+
| `resource` / `action` | Exactly as the caller sent them |
|
|
119
|
+
| `decision` | `allow` or `deny` |
|
|
120
|
+
| `code` | Deny only — the same code the caller got on the wire |
|
|
121
|
+
| `satisfiedBy` | Allow only — the `{ruleType, code}` of the rule that satisfied each group |
|
|
122
|
+
| `deniedBy` | Deny only — the first failing group, and every alternative in it that refused |
|
|
123
|
+
| `requestId` | `x-request-id` as sent, when the caller sent one. Omitted otherwise |
|
|
124
|
+
| `durationMs` | Time in the collector pipelines and the evaluator — not the HTTP round trip |
|
|
125
|
+
|
|
126
|
+
One line per decision, so a `POST /verify/batch` of N entries emits N lines sharing one `requestId`. Alert and index on the event name, never on message text.
|
|
127
|
+
|
|
128
|
+
**`LOG_LEVEL` is the switch.** The line is `info`, so setting `warn` turns the stream off; there is no second flag to forget. A deny is a *normal* outcome for a decision point and not a fault, which is why it is not routed to `warn` — doing that would let any caller manufacture warn-level noise and would stop `warn` meaning "something is wrong". Alert on the metrics, and read the log for the "why".
|
|
129
|
+
|
|
130
|
+
**Deliberately not on the line:**
|
|
131
|
+
|
|
132
|
+
- **the raw bearer token, and the claim set beyond `sub`.** A token may carry an email address, group membership or anything else the issuer chose to mint, and none of it is needed to explain a decision. This record is written on every request — successes included — and shipped to an aggregator whose blast radius is not the token's.
|
|
133
|
+
- **the caller's `context` object.** It is free-form and forwarded verbatim to collectors, so it is exactly where a calling service's own request payload ends up. Logging it would make the audit stream a copy of that payload.
|
|
134
|
+
- **rule `message` text**, which is derived from the resource and action already on the line.
|
|
135
|
+
|
|
136
|
+
### Metrics
|
|
137
|
+
|
|
138
|
+
`GET /metrics` serves the Prometheus text exposition format.
|
|
139
|
+
|
|
140
|
+
| Metric | Type | Labels | What it answers |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| `auth_decisions_total` | counter | `decision` | The allow/deny rate. Exactly two series |
|
|
143
|
+
| `auth_denials_total` | counter | `code` | Which rule is doing the denying — the aggregate of the log line's `deniedBy` |
|
|
144
|
+
| `auth_decision_duration_seconds` | histogram | `decision` | Time in the pipelines and the evaluator |
|
|
145
|
+
| `http_request_duration_seconds` | histogram | `method`, `route`, `status` | Request rate, error rate and latency (the RED method) |
|
|
146
|
+
| `auth_policy_verifier_*` | various | — | Node process defaults — event-loop lag, heap, GC, handles |
|
|
147
|
+
|
|
148
|
+
`http_request_duration_seconds` carries the same name and label set as [auth.provider](https://github.com/o3co/auth.provider)'s, so one Prometheus job and one dashboard convention cover both halves of the stack.
|
|
149
|
+
|
|
150
|
+
A deny is not an error, so the alert worth writing is on a *change* in `rate(auth_decisions_total{decision="deny"}[5m]) / rate(auth_decisions_total[5m])`, not on denials existing.
|
|
151
|
+
|
|
152
|
+
#### Every label is bounded
|
|
153
|
+
|
|
154
|
+
An unbounded label mints a fresh time series per distinct value, which is how a metrics endpoint takes down the monitoring that was supposed to watch it. None of the following needs access to `/metrics` to reach.
|
|
155
|
+
|
|
156
|
+
- **`resource` and `action` are not labels at all.** They come straight out of the request body and are unbounded by construction (`project:1`, `project:2`, …). They are on the decision log line instead, which is the right medium for a high-cardinality fact. `sub` is out for the same reason: one series per user.
|
|
157
|
+
- **`route`** is the Express route *pattern*, never the URL, and anything unmatched — 404 probes from whatever can route to the port — collapses to `route="unmatched"`.
|
|
158
|
+
- **`method`** is an allowlist of the nine methods this service can serve; everything else is `method="other"`. Node's parser hands the server every method llhttp knows (`PURGE`, `MKCOL`, `PROPFIND`, …), so `req.method` is as caller-controlled as a path.
|
|
159
|
+
- **`code`** comes from the rules a deployment configured, which makes it operator-bounded — but `code` is a field on the `Rule` interface and rules are built per request, so a custom rule collector is one edit away from deriving it from the resource. It is capped at 32 distinct values, after which the rest collapse to `code="other"`. A climbing `code="other"` is itself the signal that a rule is minting codes per request.
|
|
160
|
+
|
|
161
|
+
#### Reaching `/metrics`
|
|
162
|
+
|
|
163
|
+
`/metrics` is **not** gated by `HTTP_CALLER_AUTH_TOKEN`. Prometheus scrape configs carry `authorization`, `basic_auth` and `oauth2` — not an arbitrary header — so gating it on `x-caller-token` would make it unscrapable by a stock scraper, and the workaround would be to hand the credential that authorizes *decisions* to the monitoring system. What the endpoint publishes is counts and latencies over bounded labels: no subject, no resource, no action, nothing about any individual decision.
|
|
164
|
+
|
|
165
|
+
**The bind address is the boundary instead**, and it is loopback by default. A scraper therefore has to be on the host:
|
|
166
|
+
|
|
167
|
+
- **Kubernetes** — containers in a pod share a network namespace, so a Prometheus sidecar (or the OTel collector) in the same pod scrapes `http://127.0.0.1:3000/metrics` with the bind left alone. This is the shape that keeps the default intact.
|
|
168
|
+
- **docker compose** — `docker-compose.yml` already sets `HTTP_HOSTNAME=0.0.0.0`, because loopback inside a container is reachable from nothing. The port is then reachable by anything on the compose network, `/metrics` included: keep it off the published/public interface (`expose:` rather than `ports:`), and put the scraper on that network.
|
|
169
|
+
- **Anything else on a non-loopback bind** — restrict the port at the network layer (NetworkPolicy, security group, firewall) exactly as you would for `/verify` itself.
|
|
170
|
+
|
|
171
|
+
A scrape config for the sidecar shape:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
scrape_configs:
|
|
175
|
+
- job_name: auth-policy-verifier
|
|
176
|
+
static_configs:
|
|
177
|
+
- targets: ["127.0.0.1:3000"]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`HTTP_PATH_PREFIX` moves the endpoint with everything else: with `HTTP_PATH_PREFIX=/pdp` the path is `/pdp/metrics`, and `metrics_path` in the scrape config has to match.
|
|
181
|
+
|
|
182
|
+
**Not published yet:** a per-dependency `up` gauge (auth.provider has one for its backing stores). The equivalent here is the JWKS endpoint, and there is no readiness-probe registry to sample — a gauge built from a second, hand-maintained list of dependencies is exactly the drift auth.provider avoided by sampling its probes. Until such a registry exists, a JWKS outage is visible as the `jwt_verification_unavailable` log event, which is emitted at `error` precisely so it can be alerted on.
|
|
23
183
|
|
|
24
184
|
## Default Collectors
|
|
25
185
|
|
|
26
186
|
The following collectors are registered via `builtinCollectorsModule`:
|
|
27
187
|
|
|
28
|
-
**Attribute collectors
|
|
188
|
+
**Attribute collectors**:
|
|
29
189
|
|
|
30
|
-
- `PayloadScopeCollector` — extracts OAuth scopes
|
|
31
|
-
- `PayloadSubjectIdCollector` — extracts the subject identifier
|
|
190
|
+
- `PayloadScopeCollector` — extracts OAuth scopes from the verified subject attributes (the JWT's claims)
|
|
191
|
+
- `PayloadSubjectIdCollector` — extracts the subject identifier from the verified subject attributes (the JWT's claims)
|
|
192
|
+
- `RequestContextAttributeCollector` — promotes declared fields of the request body's `context` into attributes (not wired by default; add it to `attribute.collectors` with an `attributes` mapping list)
|
|
32
193
|
|
|
33
194
|
**Rule collectors** (resolve authorization rules):
|
|
34
195
|
|
|
35
|
-
- `ResourceActionScopeRuleCollector` —
|
|
36
|
-
- `ResourceActionPermissionRuleCollector` —
|
|
196
|
+
- `ResourceActionScopeRuleCollector` — requires the token to carry the scope `<action>:<resourceType>`
|
|
197
|
+
- `ResourceActionPermissionRuleCollector` — requires the permission `<resource.raw>.perm:<action>` (not wired by default; see below)
|
|
198
|
+
|
|
199
|
+
### The shipped policy
|
|
37
200
|
|
|
38
|
-
|
|
201
|
+
`config/application.conf` authorizes from the token's `scope` claim and nothing
|
|
202
|
+
else: a request is allowed exactly when the bearer token carries
|
|
203
|
+
`<action>:<resourceType>` for the resource and action it asks about. It is
|
|
204
|
+
functional against any issuer that mints scopes, with no authorization store to
|
|
205
|
+
stand up first, and it is fail-closed — a token with no scope, or the wrong one,
|
|
206
|
+
is denied, and so is a request that collects no rule at all
|
|
207
|
+
(`rule.onEmptyRuleSet = "deny"`).
|
|
208
|
+
|
|
209
|
+
Rules are grouped by kind and **every group must pass**. A rule collector enabled
|
|
210
|
+
without the attribute collector that feeds it therefore produces a group nothing
|
|
211
|
+
can satisfy — a verifier that denies every request, whatever the token carries.
|
|
212
|
+
That is why `ResourceActionPermissionRuleCollector` is off rather than merely
|
|
213
|
+
unconfigured: it reads the permissions/roles attributes, and nothing in the
|
|
214
|
+
shipped `attribute.collectors` produces them. Enable it and its supplier in the
|
|
215
|
+
same edit — `StaticPermissionCollector` for a fixed per-deployment list, or your
|
|
216
|
+
own `AttributeCollector` where permissions differ by subject. `application.conf`
|
|
217
|
+
carries the worked example beside the collector.
|
|
218
|
+
|
|
219
|
+
The resource parser in use is `DotNotationResourceParser`. It accepts
|
|
220
|
+
`segment *( "." segment )` where `segment = type [ ":" id ]`, and derives `resourceType` by joining
|
|
221
|
+
the segment types with `.` — so `"project:1.member:2"` is type `project.member`, id `2`. A resource
|
|
222
|
+
string outside that grammar (an empty segment such as `a..b`, a second `:` such as `a:1:2`, or
|
|
223
|
+
surrounding whitespace) is answered `400 invalid_request`; it is refused rather than repaired,
|
|
224
|
+
because `resourceType` is what the scope rules authorize.
|
|
39
225
|
|
|
40
226
|
## Adding Custom Modules
|
|
41
227
|
|
|
@@ -68,15 +254,65 @@ Build the image and run the container:
|
|
|
68
254
|
|
|
69
255
|
```sh
|
|
70
256
|
make build
|
|
71
|
-
docker run
|
|
257
|
+
docker run \
|
|
258
|
+
-p 3000:3000 \
|
|
259
|
+
-e HTTP_HOSTNAME=0.0.0.0 \
|
|
260
|
+
-e HTTP_CALLER_AUTH_TOKEN=<secret> \
|
|
261
|
+
-e OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
|
|
262
|
+
-e OAUTH_JWT_ISSUER=https://issuer.example.com \
|
|
263
|
+
-e OAUTH_JWT_AUDIENCE=https://api.example.com \
|
|
264
|
+
auth-policy-verifier
|
|
72
265
|
```
|
|
73
266
|
|
|
267
|
+
Those first two are the opt-in pair from [Trust boundary](#trust-boundary), and
|
|
268
|
+
publishing the port is exactly what makes both necessary:
|
|
269
|
+
|
|
270
|
+
- `HTTP_HOSTNAME=0.0.0.0` because the config binds loopback by default, and
|
|
271
|
+
inside a container that means reachable from nothing — the published port
|
|
272
|
+
would never connect.
|
|
273
|
+
- `HTTP_CALLER_AUTH_TOKEN` because the same setting is what puts `/verify` in
|
|
274
|
+
reach of anything that can route to the port, and `/verify` answers with an
|
|
275
|
+
authorization decision. Dropping it is only safe when nothing outside the
|
|
276
|
+
host can reach the published port.
|
|
277
|
+
|
|
74
278
|
For local development with Docker Compose:
|
|
75
279
|
|
|
76
280
|
```sh
|
|
77
281
|
make dev
|
|
78
282
|
```
|
|
79
283
|
|
|
284
|
+
`docker-compose.yml` sets `HTTP_HOSTNAME=0.0.0.0` itself and reads an optional
|
|
285
|
+
`.env`. It does **not** supply the credential — put `HTTP_CALLER_AUTH_TOKEN` in
|
|
286
|
+
that `.env`.
|
|
287
|
+
|
|
288
|
+
### `pnpm-lock.yaml` is a build input
|
|
289
|
+
|
|
290
|
+
`pnpm install --frozen-lockfile` is what keeps two builds of the same source
|
|
291
|
+
from producing two different images, so `Dockerfile` copies `pnpm-lock.yaml`
|
|
292
|
+
and the build fails without it. `create-auth-policy-verifier` generates the
|
|
293
|
+
lockfile at scaffold time — commit it. If it is missing (scaffolded offline, or
|
|
294
|
+
with `--no-lockfile`), run `pnpm install` once and commit the result.
|
|
295
|
+
|
|
296
|
+
Dependency updates are therefore a lockfile change: run `pnpm update`, commit,
|
|
297
|
+
rebuild. The base image is pinned by digest for the same reason and is bumped
|
|
298
|
+
the same way — deliberately, tag and digest together.
|
|
299
|
+
|
|
300
|
+
### What the HEALTHCHECK reports
|
|
301
|
+
|
|
302
|
+
The container healthcheck probes the container's **own routable address**, not
|
|
303
|
+
`127.0.0.1`. This is deliberate. A probe against loopback passes even when the
|
|
304
|
+
server is bound to loopback and no caller can reach it — it would report
|
|
305
|
+
"healthy" for a container that is, from outside, dead. Probing the address the
|
|
306
|
+
container is actually reachable at asks the same question a caller does, so
|
|
307
|
+
forgetting `HTTP_HOSTNAME=0.0.0.0` shows up as `unhealthy` instead of hiding.
|
|
308
|
+
|
|
309
|
+
`GET /healthcheck` is never gated by `http.callerAuth`, so the probe needs no
|
|
310
|
+
credential. It follows `HTTP_PATH_PREFIX` and `HTTP_PORT`.
|
|
311
|
+
|
|
312
|
+
One deployment shape is the exception: a sidecar sharing a network namespace
|
|
313
|
+
with its caller, where binding loopback is correct and the routable address has
|
|
314
|
+
nothing listening on it. Override `healthcheck:` on that service.
|
|
315
|
+
|
|
80
316
|
## See Also
|
|
81
317
|
|
|
82
318
|
- [`@o3co/auth.policy-verifier.server`](../../packages/server) — Express app factory and config schema
|