@o3co/create-auth-policy-verifier 0.9.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-policy-verifier",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffold a new o3co/auth.policy-verifier project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "templates"
29
29
  ],
30
30
  "devDependencies": {
31
- "@types/node": "^26.4.0",
32
- "vitest": "^4.1.11"
31
+ "@types/node": "^26.5.1",
32
+ "vitest": "^5.0.0"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -3,7 +3,7 @@
3
3
  # rebuild. The digest is the multi-arch index, so amd64 and arm64 both resolve
4
4
  # from it. Dependabot's `docker` ecosystem keeps the pair in step — bump the
5
5
  # tag and the digest together, never the tag alone.
6
- FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS node-base
6
+ FROM node:26-alpine@sha256:ef24c5053d50fdc3e4e56eb4e7ddb7861874ab0fdc797046ba897581deb8e868 AS node-base
7
7
 
8
8
  ENV HOME=/home/node
9
9
 
@@ -45,10 +45,11 @@ OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
45
45
  | `OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS` | `600000` | 取得した JWKS をキャッシュから返す期間 |
46
46
  | `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | 受け入れる `typ` ヘッダ。同じ鍵で署名された id/refresh/logout token を拒否する。`*` で pin しない(その場合 `audience` を OIDC client id にしてはいけない) |
47
47
  | `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | audience を読む claim。`azp`(Clerk)、`client_id`(Cognito)。`aud` 以外を指定すると `aud` は一切見ない |
48
- | `OAUTH_AUTHENTICATOR` | `jwt` | subject を確立する token authenticator。他の名前は `main.mts` で module が登録したもの |
48
+ | `OAUTH_AUTHENTICATOR` | `jwt` | subject を確立する token authenticator。他の名前は `main.mts` で module が登録したものでなければならない |
49
49
  | `OAUTH_JWT_MODE` | `verify` | `verify` はトークンを完全検証。明示的な `insecure-decode`(テスト専用)は署名検証なしでデコードする — `exp`/`nbf` は引き続き強制される |
50
50
  | `RULE_ON_EMPTY_RULE_SET` | `deny` | ルールが 1 つも集まらなかったときの決定(`deny` \| `allow`) |
51
51
  | `VERIFY_MAX_BATCH_SIZE` | `50` | `POST /verify/batch` の件数上限 |
52
+ | `VERIFY_CREDENTIAL_TO_COLLECTORS` | `never` | `expose` は生の資格情報を `context.credential` として collector に渡す — subject として下流 API を呼ぶ collector のためだけの設定。既定では collector は検証済みクレームだけを扱う。資格情報はリプレイ可能であり、context がログに出ればそれが漏れる |
52
53
  | `LOG_LEVEL` | `info` | 出力する最低レベル: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`。decision ログのスイッチも兼ねる — [可観測性](#可観測性)を参照 |
53
54
 
54
55
  ## HS256 シークレットの強度
@@ -102,7 +103,7 @@ Authorization: Bearer <jwt>
102
103
  **このテンプレートは初期状態でまさにその組み合わせです。** `docker-compose.yml` が `HTTP_HOSTNAME=0.0.0.0` を設定する一方、`HTTP_CALLER_AUTH_TOKEN` はどこにも設定されていません。したがって publish したポートがホスト外から到達できる状態で `docker compose up` すると、未認証の判定エンドポイントが公開されます。ローカル開発では問題なく、だからこそ warn に留めています。実トラフィックを流す前に、次のいずれかを行ってください。
103
104
 
104
105
  - **ポートをプライベートネットワークに置く。** セキュリティグループ、プライベートサブネット、Kubernetes の `NetworkPolicy` など、誰が接続してよいかを決める仕組みです。compose の `3000:3000` の publish はこれに該当しません — そのホストに到達できるすべてに到達を許します。
105
- - **`.env` に `HTTP_CALLER_AUTH_TOKEN` を設定する。** ポートに到達できるだけでは判定を要求できなくなります。攻撃者が既にネットワーク境界の内側にいる場合でも有効なのは、2 つのうちこちらだけです。なお Go の enforcement 層([protobuf.interceptors](https://github.com/o3co/protobuf.interceptors))がこのヘッダを送るオプションを備えるのは**次のリリースから**です。それまで、同ライブラリを使う呼び出し元にはネットワーク側の制御が必要です。
106
+ - **`.env` に `HTTP_CALLER_AUTH_TOKEN` を設定する。** ポートに到達できるだけでは判定を要求できなくなります。攻撃者が既にネットワーク境界の内側にいる場合でも有効なのは、2 つのうちこちらだけです。なお Go の enforcement 層([protobuf.interceptors](https://github.com/o3co/protobuf.interceptors))は v0.3.0 以降、`endpoint.WithO3coHeaders` でこのヘッダを送れます。それより前のバージョンで作られた呼び出し元にはネットワーク側の制御が必要です。
106
107
  - **あるいは `HTTP_HOSTNAME` を外し、verifier をサイドカーとして動かす。** enforcement 層とネットワーク名前空間を共有すれば、ループバックで足ります。
107
108
 
108
109
  `GET /metrics` も非ゲートです。理由と、ループバック以外に bind したときに何を意味するかは [`/metrics` への到達方法](#metrics-への到達方法)を参照してください。
@@ -127,7 +128,7 @@ Authorization: Bearer <jwt>
127
128
  | `code` | deny のみ — 呼び出し元がワイヤ上で受け取ったコードと同じ |
128
129
  | `satisfiedBy` | allow のみ — 各グループを満たしたルールの `{ruleType, code}` |
129
130
  | `deniedBy` | deny のみ — 最初に失敗したグループと、そこで拒否した全代替ルール |
130
- | `requestId` | 呼び出し元が送った `x-request-id`。無い場合は省略される |
131
+ | `requestId` | 呼び出し元が送った `x-request-id`(サーバーが受け入れる形のもの。[失敗イベント](#失敗イベント) を参照)。無い場合は省略される |
131
132
  | `durationMs` | Collector パイプラインと evaluator に費やした時間。HTTP の往復時間ではない |
132
133
 
133
134
  判定 1 件につき 1 行なので、N 件の `POST /verify/batch` は同一 `requestId` を持つ N 行を出力します。アラートとインデックスはイベント名に対して張り、メッセージ本文には張らないでください。
@@ -140,6 +141,37 @@ Authorization: Bearer <jwt>
140
141
  - **呼び出し元の `context` オブジェクト。** 自由形式で collector にそのまま渡されるため、呼び出し側サービスのリクエストペイロードが行き着く場所そのものです。これを記録すると監査ストリームがそのペイロードの複製になります。
141
142
  - **ルールの `message` 文字列。** 既に行に載っている resource と action から導かれる内容です。
142
143
 
144
+ ### 失敗イベント
145
+
146
+ 判定できなかった decision は `error`(level 50)でログに出て、その行はどの種類の失敗がどこで起きたかを示します。
147
+
148
+ ```json
149
+ {"level":50,"msg":"verify_internal_error","endpoint":"/verify","requestId":"6f1c…","category":"collector_threw","collector":"attribute.collectors[1] (EntitlementStoreCollector)","err":{"type":"Error","message":"entitlement store is down","stack":"…"}}
150
+ ```
151
+
152
+ | イベント | 応答 | `category` | 併せて示すもの |
153
+ |---|---|---|---|
154
+ | `collector_timeout` | `403 collector_timeout` | `collector_timeout` | `collector`: `VERIFY_COLLECTOR_TIMEOUT_MS` を超えたエントリ、または pipeline が `VERIFY_COLLECTOR_DEADLINE_MS` を超えた場合はリストそのもの(`attribute.collectors`) |
155
+ | `rule_timeout` | `403 rule_timeout` | `rule_timeout` | `rule`: `{ ruleType, code }` |
156
+ | `attribute_conflict` | `403 attribute_conflict` | `attribute_conflict` | —(2 つのコレクターが食い違った。どちらか一方だけの責任ではない) |
157
+ | `verify_internal_error` | `500 internal_error` | `collector_threw`、`rule_threw`、`body_rejected`、`internal` のいずれか | `collector_threw` なら `collector`、`rule_threw` なら `rule` |
158
+
159
+ | `category` | 何が失敗したか |
160
+ |---|---|
161
+ | `collector_timeout` | コレクターが予算を、または pipeline がデッドラインを超えた |
162
+ | `collector_threw` | コレクターが reject / throw した |
163
+ | `attribute_conflict` | 2 つの attribute コレクターが同じスカラーキーに異なる値を書いた |
164
+ | `rule_timeout` | 非同期ルールが予算を、またはルールフェーズがデッドラインを超えた |
165
+ | `rule_threw` | ルールの `verify` が throw した、または `decide` が reject した |
166
+ | `body_rejected` | JSON body parser が、deny エンベロープが 4xx に対応付けていない形で失敗した |
167
+ | `internal` | decision 自身の collect や評価から出てきたのではないもの — throw した resource parser や authenticator(何を throw したかによらず) |
168
+
169
+ この集合は閉じています: アラートとフィルタは `err.message` ではなく `category` の等値に対して張ってください。`collector` は `config/application.conf` の `attribute.collectors` / `rule.collectors` 内のエントリ位置とクラス名です。JWKS に到達できないことは category ではありません — `401` で応答され、`jwt_verification_unavailable` としてログに出ます(後述)。
170
+
171
+ サーバーがこれらの行に加えるものは、トークン・クレーム・`context` のいずれも含みません。collector はその decision についてコレクターのランナーが記録したもので、エラーから読んだ名前ではなく、何も記録されていないタイムアウトは `collector: "unattributed"` です。ルールの `ruleType` と `code` はルールコレクターがリクエストごとにルールを組み立てうるため、識別子の形(英字で始まり、英数字・`_`・`.`・`-` が続く 64 文字以内)の場合だけ載り、それ以外は `redacted` になります。リクエスト ID は検証済みです。`err` は throw されたエラーそのものですが、例外が 1 つあります: core が定義する 3 つの deny エラー(`CollectorTimeoutError`、`RuleTimeoutError`、`AttributeConflictError`)はメッセージと自身のフィールドにコレクター・ルール・属性キーを含むため、分類されたコレクターやルール、同じ識別子の形に制限した属性キー、ヘッダ行だけの stack で組み立て直したものを記録します。それ以外のエラーのメッセージは、その作者の責任です。
172
+
173
+ **リクエストの相関.** 呼び出し元が送った `x-request-id` は、`/verify` と `/verify/batch` が返すすべての応答 — allow、deny、拒否、`500` — にレスポンスヘッダとしてそのまま返され、これらの行と `decision` 行に `requestId` として載ります。これで deny を enforcement 側サービス自身のログと突き合わせられます。載せるのは `A-Z a-z 0-9 - _ . : + / = #` からなる 1〜128 文字の場合だけで、UUID、ULID、16 進や W3C のトレース ID、base64、[protobuf.interceptors](https://github.com/o3co/protobuf.interceptors) が採番する ID はすべて収まります。それ以外は送られなかったものとして扱い — 返さず、ログに出さず、コレクターにも渡しません — 呼び出し元が送らなかった場合にサーバーが採番することもありません。`HTTP_CALLER_AUTH_TOKEN` のゲートは decision エンドポイントより手前で応答するため、ID を返しません。
174
+
143
175
  ### メトリクス
144
176
 
145
177
  `GET /metrics` は Prometheus text exposition format を返します。
@@ -149,6 +181,7 @@ Authorization: Bearer <jwt>
149
181
  | `auth_decisions_total` | counter | `decision` | allow/deny 比率。系列はちょうど 2 本 |
150
182
  | `auth_denials_total` | counter | `code` | どのルールが deny しているか — ログ行の `deniedBy` の集計版 |
151
183
  | `auth_decision_duration_seconds` | histogram | `decision` | パイプラインと evaluator に費やした時間 |
184
+ | `auth_collector_failures_total` | counter | `collector`, `category` | どのファクトソースがどう判定を失敗させているか — 失敗イベント行の `collector` フィールドの集計版。`category` は `collector_timeout` か `collector_threw` |
152
185
  | `http_request_duration_seconds` | histogram | `method`, `route`, `status` | リクエストレート・エラーレート・レイテンシ(RED メソッド) |
153
186
  | `auth_policy_verifier_*` | 各種 | — | Node プロセス既定メトリクス — event loop lag, heap, GC, handles |
154
187
 
@@ -164,6 +197,7 @@ deny はエラーではないので、書く価値のあるアラートは deny
164
197
  - **`route`** は URL ではなく Express の route *パターン*で、マッチしなかったもの(ポートに到達できる何かからの 404 プローブ)はすべて `route="unmatched"` に潰れます。
165
198
  - **`method`** はこのサービスが実際に処理できる 9 メソッドの allowlist で、それ以外は `method="other"` です。Node のパーサは llhttp が知る全メソッド(`PURGE`, `MKCOL`, `PROPFIND` など)をサーバーに渡すため、`req.method` はパスと同程度に呼び出し元の制御下にあります。
166
199
  - **`code`** は deployment が設定したルールに由来するので運用者が有界にできます — ただし `code` は `Rule` インターフェースのフィールドであり、ルールはリクエストごとに構築されるため、カスタム rule collector が resource から code を導出するようになるまでは 1 回の編集です。異なる値 32 個で打ち止め、それ以降は `code="other"` に潰れます。`code="other"` が伸びていること自体が、リクエストごとに code を作っているルールがあるというシグナルです。
200
+ - **`collector`** は設定されたエントリ — コレクターのランナーが記録した `attribute.collectors` / `rule.collectors` 内の位置と識別子の形のクラス名 — または `collector="unattributed"` なので、設定によって有界です。コレクターが自分で作った `CollectorTimeoutError` で別の名前を名乗ることはできません。それでも念のため異なる値 32 個で打ち止め、それ以降は `collector="other"` に潰れます。**`category`** は閉じた列挙値です。
167
201
 
168
202
  #### `/metrics` への到達方法
169
203
 
@@ -289,6 +323,31 @@ make dev
289
323
  を読み込みます。資格情報は供給**しません** — `HTTP_CALLER_AUTH_TOKEN` はその
290
324
  `.env` に置いてください。
291
325
 
326
+ ### プロセス外の Cedar: `--profile cedar`
327
+
328
+ [Cedar](../../packages/cedar/README.md) のポリシーをプロセス外で評価するデプロイは、
329
+ アプリの隣でエージェントを起動します:
330
+
331
+ ```sh
332
+ echo "CEDAR_AUTHENTICATION=$(openssl rand -hex 32)" >> .env
333
+ docker compose --profile cedar up --build
334
+ ```
335
+
336
+ `CEDAR_AUTHENTICATION` は必須です: エージェントはこれをトークンとして起動され、
337
+ アプリはすべての呼び出しでこれを送ります。認証されていないエージェントは、
338
+ 到達できる何者にもポリシーセットの差し替えを許してしまうからです。未設定の
339
+ ままだとエージェントはすべての呼び出しを拒否し、verifier は起動時にこれを
340
+ 設定するよう伝えます。profile を付けない素の `docker compose up` では不要です。
341
+
342
+ `cedar-engine` サービスはアプリコンテナとネットワーク名前空間を共有するので、
343
+ verifier の `http` engine は `http://127.0.0.1:8180` でそれを見つけます — engine
344
+ には既定のアドレスがないため、これは compose ファイルがアプリに設定する
345
+ `CEDAR_ENDPOINT` です。verifier は起動時に `config/policies` をそこへ push し、
346
+ このコンテナの組の外からは誰も到達できません。この profile は、アプリが
347
+ `cedarPolicyModule` を組み込むまで何もしません。パッケージ・モジュール・config
348
+ エントリについては cedar の README を、どういうときにこの形が in-process の形に
349
+ 勝るかについてはその sizing 表を参照してください。
350
+
292
351
  ### `pnpm-lock.yaml` はビルド入力
293
352
 
294
353
  同じソースからの 2 回のビルドが 2 つの異なるイメージを生まないようにするのが
@@ -103,7 +103,7 @@ Leaving the credential unset is supported and is fine on loopback. On a non-loop
103
103
  **This template is that combination out of the box.** `HTTP_HOSTNAME=0.0.0.0` is set in `docker-compose.yml` and `HTTP_CALLER_AUTH_TOKEN` is not set anywhere, so a `docker compose up` with the published port reachable from off-host is an unauthenticated decision endpoint. That is fine for local development and is why the warning is a warning. Before it carries traffic, do one of:
104
104
 
105
105
  - **Put the port on a private network.** A security group, a private subnet, a Kubernetes `NetworkPolicy` — something that decides who may connect. Publishing `3000:3000` from compose is not this: it reaches everything that can route to the host.
106
- - **Set `HTTP_CALLER_AUTH_TOKEN` in `.env`.** Then reaching the port is not enough to ask for a decision, which is the only one of the two that still holds against an attacker already inside the network boundary. Note that the Go enforcement layer ([protobuf.interceptors](https://github.com/o3co/protobuf.interceptors)) gains the option to send this header **from its next release**; until then a caller built on it needs the network control instead.
106
+ - **Set `HTTP_CALLER_AUTH_TOKEN` in `.env`.** Then reaching the port is not enough to ask for a decision, which is the only one of the two that still holds against an attacker already inside the network boundary. Note that the Go enforcement layer ([protobuf.interceptors](https://github.com/o3co/protobuf.interceptors)) sends this header from v0.3.0 on, through `endpoint.WithO3coHeaders`; a caller built on an earlier version needs the network control instead.
107
107
  - **Or drop `HTTP_HOSTNAME` and run the verifier as a sidecar**, sharing a network namespace with the enforcement layer so loopback is enough.
108
108
 
109
109
  `GET /metrics` is not gated either — see [Reaching `/metrics`](#reaching-metrics) for why, and for what that means when the bind is not loopback.
@@ -128,7 +128,7 @@ Every decision emits one structured event named `decision`, at `info`:
128
128
  | `code` | Deny only — the same code the caller got on the wire |
129
129
  | `satisfiedBy` | Allow only — the `{ruleType, code}` of the rule that satisfied each group |
130
130
  | `deniedBy` | Deny only — the first failing group, and every alternative in it that refused |
131
- | `requestId` | `x-request-id` as sent, when the caller sent one. Omitted otherwise |
131
+ | `requestId` | `x-request-id` as sent, when the caller sent one the server carries (see [Failure events](#failure-events)). Omitted otherwise |
132
132
  | `durationMs` | Time in the collector pipelines and the evaluator — not the HTTP round trip |
133
133
 
134
134
  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.
@@ -141,6 +141,37 @@ One line per decision, so a `POST /verify/batch` of N entries emits N lines shar
141
141
  - **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.
142
142
  - **rule `message` text**, which is derived from the resource and action already on the line.
143
143
 
144
+ ### Failure events
145
+
146
+ A decision that could not be made is logged at `error` (level 50), and the line says what kind of failure it was and where:
147
+
148
+ ```json
149
+ {"level":50,"msg":"verify_internal_error","endpoint":"/verify","requestId":"6f1c…","category":"collector_threw","collector":"attribute.collectors[1] (EntitlementStoreCollector)","err":{"type":"Error","message":"entitlement store is down","stack":"…"}}
150
+ ```
151
+
152
+ | Event | Answered | `category` | Also names |
153
+ |---|---|---|---|
154
+ | `collector_timeout` | `403 collector_timeout` | `collector_timeout` | `collector`: the entry that overran `VERIFY_COLLECTOR_TIMEOUT_MS`, or the list itself (`attribute.collectors`) when the pipeline overran `VERIFY_COLLECTOR_DEADLINE_MS` |
155
+ | `rule_timeout` | `403 rule_timeout` | `rule_timeout` | `rule`: `{ ruleType, code }` |
156
+ | `attribute_conflict` | `403 attribute_conflict` | `attribute_conflict` | — (two collectors disagreed; neither alone is answerable) |
157
+ | `verify_internal_error` | `500 internal_error` | `collector_threw`, `rule_threw`, `body_rejected` or `internal` | `collector` for `collector_threw`, `rule` for `rule_threw` |
158
+
159
+ | `category` | What failed |
160
+ |---|---|
161
+ | `collector_timeout` | A collector overran its budget, or a pipeline its deadline |
162
+ | `collector_threw` | A collector rejected or threw |
163
+ | `attribute_conflict` | Two attribute collectors wrote different values to one scalar key |
164
+ | `rule_timeout` | An asynchronous rule overran its budget, or the rule phase its deadline |
165
+ | `rule_threw` | A rule's `verify` threw or its `decide` rejected |
166
+ | `body_rejected` | The JSON body parser failed in a way the deny envelope does not map to a 4xx |
167
+ | `internal` | Anything that did not come out of a decision's own collect or evaluation — a resource parser or authenticator that threw, whatever it threw |
168
+
169
+ The set is closed: alert and filter on `category` by equality, never on `err.message`. `collector` is the entry's position in `attribute.collectors` / `rule.collectors` in `config/application.conf`, plus its class. An unreachable JWKS is not a category — it is answered `401` and logged as `jwt_verification_unavailable` (see below).
170
+
171
+ Nothing the server adds to these lines carries the token, the claims or `context`. The collector is the one the collector runner recorded for that decision, never a name read off an error, and a timeout nothing recorded is `collector: "unattributed"`. A rule's `ruleType` and `code` are carried only when identifier-shaped (a letter, then letters, digits, `_`, `.` or `-`, at most 64 characters) and are `redacted` otherwise, because a rule collector may build rules per request. The request id is validated. `err` is the error as thrown, with one exception: the three deny errors core defines (`CollectorTimeoutError`, `RuleTimeoutError`, `AttributeConflictError`) name a collector, a rule or an attribute key in their message and their own fields, so each is logged rebuilt — the classified collector or rule, the key held to the same identifier shape, and a stack of the header line only. Any other error's message is its author's responsibility.
172
+
173
+ **Request correlation.** An `x-request-id` the caller sent is echoed as a response header on every answer `/verify` and `/verify/batch` give — allow, deny, refusal and `500` — and carried as `requestId` on these lines and the `decision` line, so a denial can be matched to the enforcing service's own log. It is carried only as 1–128 characters of `A-Z a-z 0-9 - _ . : + / = #`, which UUIDs, ULIDs, hex and W3C trace ids, base64 and the ids [protobuf.interceptors](https://github.com/o3co/protobuf.interceptors) mints all fit. Anything else is treated as absent — not echoed, not logged, not forwarded to collectors — and the server never mints one when the caller sent none. The `HTTP_CALLER_AUTH_TOKEN` gate answers before the decision endpoints and does not echo it.
174
+
144
175
  ### Metrics
145
176
 
146
177
  `GET /metrics` serves the Prometheus text exposition format.
@@ -150,6 +181,7 @@ One line per decision, so a `POST /verify/batch` of N entries emits N lines shar
150
181
  | `auth_decisions_total` | counter | `decision` | The allow/deny rate. Exactly two series |
151
182
  | `auth_denials_total` | counter | `code` | Which rule is doing the denying — the aggregate of the log line's `deniedBy` |
152
183
  | `auth_decision_duration_seconds` | histogram | `decision` | Time in the pipelines and the evaluator |
184
+ | `auth_collector_failures_total` | counter | `collector`, `category` | Which fact source is failing decisions, and how — the aggregate of the `collector` field on the failure lines. `category` is `collector_timeout` or `collector_threw` |
153
185
  | `http_request_duration_seconds` | histogram | `method`, `route`, `status` | Request rate, error rate and latency (the RED method) |
154
186
  | `auth_policy_verifier_*` | various | — | Node process defaults — event-loop lag, heap, GC, handles |
155
187
 
@@ -165,6 +197,7 @@ An unbounded label mints a fresh time series per distinct value, which is how a
165
197
  - **`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"`.
166
198
  - **`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.
167
199
  - **`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.
200
+ - **`collector`** is a configured entry — its position in `attribute.collectors` / `rule.collectors` and its identifier-shaped class name, as the collector runner recorded it — or `collector="unattributed"`, so it is bounded by the config. A `CollectorTimeoutError` a collector builds itself cannot name anything else. The label is still capped at 32 distinct values as a backstop, collapsing to `collector="other"`. **`category`** is a closed enum.
168
201
 
169
202
  #### Reaching `/metrics`
170
203
 
@@ -293,6 +326,31 @@ make dev
293
326
  `.env`. It does **not** supply the credential — put `HTTP_CALLER_AUTH_TOKEN` in
294
327
  that `.env`.
295
328
 
329
+ ### Out-of-process Cedar: `--profile cedar`
330
+
331
+ A deployment that evaluates [Cedar](../../packages/cedar/README.md) policies
332
+ out of process starts the agent beside the app:
333
+
334
+ ```sh
335
+ echo "CEDAR_AUTHENTICATION=$(openssl rand -hex 32)" >> .env
336
+ docker compose --profile cedar up --build
337
+ ```
338
+
339
+ `CEDAR_AUTHENTICATION` is required: the agent is started with it as its token
340
+ and the app sends it on every call, because an unauthenticated agent lets
341
+ anything that reaches it replace the policy set. Left unset, the agent refuses
342
+ every call and the verifier's boot says to set it; a plain `docker compose up`
343
+ without the profile does not need it.
344
+
345
+ The `cedar-engine` service shares the app container's network namespace, so
346
+ the verifier's `http` engine finds it at `http://127.0.0.1:8180` — the
347
+ `CEDAR_ENDPOINT` the compose file sets on the app, since the engine has no
348
+ default address — pushes `config/policies` into
349
+ it at boot, and nothing outside the container pair can reach it. The profile
350
+ does nothing until the app composes `cedarPolicyModule`; see cedar's README
351
+ for the packages, the module and the config entry — and its sizing table for
352
+ when this shape beats the in-process one.
353
+
296
354
  ### `pnpm-lock.yaml` is a build input
297
355
 
298
356
  `pnpm install --frozen-lockfile` is what keeps two builds of the same source
@@ -328,6 +328,16 @@ verify {
328
328
  # the concurrency cap does not spend it.
329
329
  collectorTimeoutMs = 2000
330
330
  collectorTimeoutMs = ${?VERIFY_COLLECTOR_TIMEOUT_MS}
331
+ # How long one asynchronous rule (an out-of-process policy engine, #225) may
332
+ # take to answer. Same budget and ceiling as one collector; overrun = deny.
333
+ ruleTimeoutMs = 2000
334
+ ruleTimeoutMs = ${?VERIFY_RULE_TIMEOUT_MS}
335
+ # How long all of a decision's asynchronous rules may take together. Rule
336
+ # groups run one after another, so `ruleTimeoutMs` bounds each rule and not
337
+ # the phase; this does, the way `collectorDeadlineMs` does for collectors.
338
+ # An asynchronous rule runs under whichever ends first. Overrun = deny.
339
+ evaluateDeadlineMs = 5000
340
+ evaluateDeadlineMs = ${?VERIFY_EVALUATE_DEADLINE_MS}
331
341
 
332
342
  # `collectorDeadlineMs` is the whole fan-out, per pipeline. The per-collector
333
343
  # budget cannot bound a SET: once collectors queue, enough of them each
@@ -15,6 +15,45 @@ services:
15
15
  # and it is why HTTP_CALLER_AUTH_TOKEN is worth setting in .env: past the
16
16
  # container boundary, anyone who can reach the port can ask for decisions.
17
17
  - HTTP_HOSTNAME=0.0.0.0
18
+ # Where the Cedar http engine finds the `cedar-engine` profile below — read
19
+ # only when the app composes cedarPolicyModule without the wasm package.
20
+ # The engine has no default address (a missing one refuses to start,
21
+ # naming this variable and the wasm package), so it is stated here, as an
22
+ # interpolation default: an `environment` entry outranks `env_file`, so a
23
+ # literal value would ignore a CEDAR_ENDPOINT set in .env for another agent.
24
+ - CEDAR_ENDPOINT=${CEDAR_ENDPOINT:-http://127.0.0.1:8180}
25
+ # The token the app sends the agent, from the same interpolation the
26
+ # `cedar-engine` service below starts the agent with — so a value from the
27
+ # shell or `--env-file` reaches both sides, not only the agent.
28
+ - CEDAR_AUTHENTICATION=${CEDAR_AUTHENTICATION:-}
18
29
  env_file:
19
30
  - path: .env
20
31
  required: false
32
+
33
+ # Out-of-process Cedar engine, for deployments that evaluate Cedar policies
34
+ # through @o3co/auth.policy-verifier.cedar without the in-process wasm
35
+ # package (its README, "Running out of process"). Opt in per run:
36
+ #
37
+ # docker compose --profile cedar up --build
38
+ #
39
+ # It shares the app container's network namespace, so the verifier reaches
40
+ # it on loopback at CEDAR_ENDPOINT (http://127.0.0.1:8180, set on the app
41
+ # above), and nothing outside the pair can reach it at all —
42
+ # the same trust boundary the verifier's own bind address draws. The
43
+ # verifier pushes its config/policies into the agent at boot; nothing is
44
+ # mounted here. Pinned by digest for the reason the base image is.
45
+ cedar-engine:
46
+ image: permitio/cedar-agent:0.2.2@sha256:2a7713bce95220eb2ad870ba311f1ae8a4d6e607d13720747d75b96cb0c551ca
47
+ profiles: ["cedar"]
48
+ network_mode: "service:app"
49
+ environment:
50
+ - CEDAR_AGENT_ADDR=127.0.0.1
51
+ - CEDAR_AGENT_PORT=8180
52
+ # Required: an unauthenticated agent is a write oracle over the policy set
53
+ # — anything that reaches its port can PUT a `permit` for everyone. Set
54
+ # CEDAR_AUTHENTICATION in .env; the app reads the same variable and sends
55
+ # it. Unset, the agent starts with an empty token and refuses every call,
56
+ # and the verifier's boot says to set CEDAR_AUTHENTICATION. (Not compose's
57
+ # required-variable form: it is interpolated for every service, profile or
58
+ # not, and would refuse a plain `docker compose up` too.)
59
+ - CEDAR_AGENT_AUTHENTICATION=${CEDAR_AUTHENTICATION:-}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@o3co/auth-policy-verifier-standalone",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.29.3",
7
7
  "type": "module",
8
8
  "engines": {
9
- "node": ">=22.0.0"
9
+ "node": ">=22.12.0"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
@@ -24,12 +24,12 @@
24
24
  "pino": "^10.3.1"
25
25
  },
26
26
  "devDependencies": {
27
- "@types/node": "^26.4.0",
27
+ "@types/node": "^26.5.1",
28
28
  "@types/supertest": "^7.2.1",
29
- "jose": "^6.2.10",
29
+ "jose": "^6.2.12",
30
30
  "supertest": "^7.2.2",
31
- "tsx": "^4.23.12",
31
+ "tsx": "^4.23.13",
32
32
  "typescript": "^5.9.3",
33
- "vitest": "^4.1.11"
33
+ "vitest": "^5.0.0"
34
34
  }
35
35
  }
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ const compose = readFileSync(new URL("../../docker-compose.yml", import.meta.url), "utf8");
8
+
9
+ describe("docker-compose.yml — the cedar profile's agent is authenticated (v0.10.0 audit)", () => {
10
+ it("starts the agent with the token the app sends", () => {
11
+ // An unauthenticated cedar-agent is a write oracle over the policy set:
12
+ // anything that reaches its port can PUT `permit(principal, action,
13
+ // resource);`. The token comes from the same CEDAR_AUTHENTICATION the app
14
+ // reads, so the two cannot disagree.
15
+ expect(compose).toMatch(/- CEDAR_AGENT_AUTHENTICATION=\$\{CEDAR_AUTHENTICATION:-\}/);
16
+ // And the app from the same interpolation, not only through `env_file`:
17
+ // a shell variable or `--env-file` would otherwise reach the agent alone
18
+ // (review).
19
+ expect(compose).toMatch(/- CEDAR_AUTHENTICATION=\$\{CEDAR_AUTHENTICATION:-\}/);
20
+ });
21
+
22
+ it("does not make a plain `docker compose up` depend on it", () => {
23
+ // `${VAR:?}` is interpolated for every service, profile or not, so it
24
+ // would refuse the app-only stack too. Unset, the agent is started with
25
+ // an empty token, refuses every call, and the verifier's boot names
26
+ // CEDAR_AUTHENTICATION — closed, without breaking the default stack.
27
+ expect(compose).not.toMatch(/:\?/);
28
+ });
29
+ });
@@ -55,6 +55,8 @@ const envKeys = [
55
55
  "VERIFY_COLLECTOR_TIMEOUT_MS",
56
56
  "VERIFY_COLLECTOR_DEADLINE_MS",
57
57
  "VERIFY_COLLECTOR_CONCURRENCY",
58
+ "VERIFY_RULE_TIMEOUT_MS",
59
+ "VERIFY_EVALUATE_DEADLINE_MS",
58
60
  ] as const;
59
61
 
60
62
  /** application.conf leaves issuer/audience unset, so every load must supply them. */
@@ -268,6 +270,8 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
268
270
  process.env.VERIFY_COLLECTOR_TIMEOUT_MS = "750";
269
271
  process.env.VERIFY_COLLECTOR_DEADLINE_MS = "1500";
270
272
  process.env.VERIFY_COLLECTOR_CONCURRENCY = "4";
273
+ process.env.VERIFY_RULE_TIMEOUT_MS = "900";
274
+ process.env.VERIFY_EVALUATE_DEADLINE_MS = "2500";
271
275
 
272
276
  const config = loadAppConfig(configDirPath, "development");
273
277
 
@@ -286,6 +290,8 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
286
290
  expect(config.verify.collectorTimeoutMs).toBe(750);
287
291
  expect(config.verify.collectorDeadlineMs).toBe(1_500);
288
292
  expect(config.verify.collectorConcurrency).toBe(4);
293
+ expect(config.verify.ruleTimeoutMs).toBe(900);
294
+ expect(config.verify.evaluateDeadlineMs).toBe(2_500);
289
295
  });
290
296
 
291
297
  it.each([
@@ -304,6 +310,8 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
304
310
  ["VERIFY_COLLECTOR_TIMEOUT_MS", "0"],
305
311
  ["VERIFY_COLLECTOR_DEADLINE_MS", "-1"],
306
312
  ["VERIFY_COLLECTOR_CONCURRENCY", "0"],
313
+ ["VERIFY_RULE_TIMEOUT_MS", "0"],
314
+ ["VERIFY_EVALUATE_DEADLINE_MS", "-1"],
307
315
  ])("refuses to boot on %s=%s", (key, value) => {
308
316
  setRequiredEnv();
309
317
  process.env[key] = value;
@@ -381,7 +389,7 @@ rule { collectors = [ { collector = ResourceActionScopeRuleCollector } ] }
381
389
 
382
390
  const config = loadAppConfig(withoutVerify(), "development");
383
391
 
384
- // All eleven, spelled out. A knob that reached the shape but not the
392
+ // All twelve, spelled out. A knob that reached the shape but not the
385
393
  // block's `.default()` literal is `undefined` here, and `toEqual` says
386
394
  // which one.
387
395
  expect(config.verify).toEqual({
@@ -392,6 +400,8 @@ rule { collectors = [ { collector = ResourceActionScopeRuleCollector } ] }
392
400
  maxContextEntries: 64,
393
401
  maxContextValueLength: 1_024,
394
402
  collectorTimeoutMs: 2_000,
403
+ ruleTimeoutMs: 2_000,
404
+ evaluateDeadlineMs: 5_000,
395
405
  collectorDeadlineMs: 5_000,
396
406
  collectorConcurrency: 8,
397
407
  batchConcurrency: 8,
@@ -1,5 +1,5 @@
1
1
  {
2
- "@o3co/auth.policy-verifier.core": "0.9.0",
3
- "@o3co/auth.policy-verifier.builtins": "0.9.0",
4
- "@o3co/auth.policy-verifier.server": "0.9.0"
2
+ "@o3co/auth.policy-verifier.core": "0.11.0",
3
+ "@o3co/auth.policy-verifier.builtins": "0.11.0",
4
+ "@o3co/auth.policy-verifier.server": "0.11.0"
5
5
  }