@o3co/create-auth-policy-verifier 0.9.0 → 0.10.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.10.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-への到達方法)を参照してください。
@@ -289,6 +290,31 @@ make dev
289
290
  を読み込みます。資格情報は供給**しません** — `HTTP_CALLER_AUTH_TOKEN` はその
290
291
  `.env` に置いてください。
291
292
 
293
+ ### プロセス外の Cedar: `--profile cedar`
294
+
295
+ [Cedar](../../packages/cedar/README.md) のポリシーをプロセス外で評価するデプロイは、
296
+ アプリの隣でエージェントを起動します:
297
+
298
+ ```sh
299
+ echo "CEDAR_AUTHENTICATION=$(openssl rand -hex 32)" >> .env
300
+ docker compose --profile cedar up --build
301
+ ```
302
+
303
+ `CEDAR_AUTHENTICATION` は必須です: エージェントはこれをトークンとして起動され、
304
+ アプリはすべての呼び出しでこれを送ります。認証されていないエージェントは、
305
+ 到達できる何者にもポリシーセットの差し替えを許してしまうからです。未設定の
306
+ ままだとエージェントはすべての呼び出しを拒否し、verifier は起動時にこれを
307
+ 設定するよう伝えます。profile を付けない素の `docker compose up` では不要です。
308
+
309
+ `cedar-engine` サービスはアプリコンテナとネットワーク名前空間を共有するので、
310
+ verifier の `http` engine は `http://127.0.0.1:8180` でそれを見つけます — engine
311
+ には既定のアドレスがないため、これは compose ファイルがアプリに設定する
312
+ `CEDAR_ENDPOINT` です。verifier は起動時に `config/policies` をそこへ push し、
313
+ このコンテナの組の外からは誰も到達できません。この profile は、アプリが
314
+ `cedarPolicyModule` を組み込むまで何もしません。パッケージ・モジュール・config
315
+ エントリについては cedar の README を、どういうときにこの形が in-process の形に
316
+ 勝るかについてはその sizing 表を参照してください。
317
+
292
318
  ### `pnpm-lock.yaml` はビルド入力
293
319
 
294
320
  同じソースからの 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.
@@ -293,6 +293,31 @@ make dev
293
293
  `.env`. It does **not** supply the credential — put `HTTP_CALLER_AUTH_TOKEN` in
294
294
  that `.env`.
295
295
 
296
+ ### Out-of-process Cedar: `--profile cedar`
297
+
298
+ A deployment that evaluates [Cedar](../../packages/cedar/README.md) policies
299
+ out of process starts the agent beside the app:
300
+
301
+ ```sh
302
+ echo "CEDAR_AUTHENTICATION=$(openssl rand -hex 32)" >> .env
303
+ docker compose --profile cedar up --build
304
+ ```
305
+
306
+ `CEDAR_AUTHENTICATION` is required: the agent is started with it as its token
307
+ and the app sends it on every call, because an unauthenticated agent lets
308
+ anything that reaches it replace the policy set. Left unset, the agent refuses
309
+ every call and the verifier's boot says to set it; a plain `docker compose up`
310
+ without the profile does not need it.
311
+
312
+ The `cedar-engine` service shares the app container's network namespace, so
313
+ the verifier's `http` engine finds it at `http://127.0.0.1:8180` — the
314
+ `CEDAR_ENDPOINT` the compose file sets on the app, since the engine has no
315
+ default address — pushes `config/policies` into
316
+ it at boot, and nothing outside the container pair can reach it. The profile
317
+ does nothing until the app composes `cedarPolicyModule`; see cedar's README
318
+ for the packages, the module and the config entry — and its sizing table for
319
+ when this shape beats the in-process one.
320
+
296
321
  ### `pnpm-lock.yaml` is a build input
297
322
 
298
323
  `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.10.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.10.0",
3
+ "@o3co/auth.policy-verifier.builtins": "0.10.0",
4
+ "@o3co/auth.policy-verifier.server": "0.10.0"
5
5
  }