@o3co/create-auth-policy-verifier 0.14.0 → 0.16.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/dist/index.d.mts +11 -17
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +18 -29
- package/package.json +3 -3
- package/templates/standalone/Dockerfile +7 -8
- package/templates/standalone/README.ja.md +8 -8
- package/templates/standalone/README.md +8 -8
- package/templates/standalone/config/application.conf +23 -17
- package/templates/standalone/docker-compose.yml +3 -2
- package/templates/standalone/package.json +4 -4
- package/templates/standalone/src/__tests__/dockerCompose.test.mts +2 -3
- package/templates/standalone/src/__tests__/loadConfig.test.mts +25 -32
- package/templates/standalone/src/__tests__/logger.test.mts +5 -8
- package/templates/standalone/src/__tests__/shutdown.test.mts +11 -21
- package/templates/standalone/src/__tests__/smoke.test.mts +18 -30
- package/templates/standalone/src/logger.ts +11 -13
- package/templates/standalone/src/shutdown.ts +12 -27
- package/templates/versions.json +3 -3
package/dist/index.d.mts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Decides whether one entry of the template tree is copied into a new project.
|
|
3
3
|
*
|
|
4
|
-
* The exclusion is judged on the path
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* absolute path therefore rejected the entire template and produced an empty
|
|
9
|
-
* project directory — the scaffolder only ever worked from a source checkout.
|
|
4
|
+
* The exclusion is judged on the path relative to the template root. Run the
|
|
5
|
+
* documented way (`npm create`, `npx`), the scaffolder sits inside
|
|
6
|
+
* `node_modules`, so every absolute source path contains a `node_modules`
|
|
7
|
+
* segment and matching on it would exclude the whole template.
|
|
10
8
|
*/
|
|
11
9
|
export declare const isTemplateEntryIncluded: (templatesDir: string, source: string) => boolean;
|
|
12
10
|
export declare const isValidProjectName: (name: string) => boolean;
|
|
@@ -29,18 +27,14 @@ export type LockfileResult = {
|
|
|
29
27
|
readonly reason: string;
|
|
30
28
|
};
|
|
31
29
|
/**
|
|
32
|
-
* Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir
|
|
30
|
+
* Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir`, for the
|
|
31
|
+
* template's `pnpm install --frozen-lockfile` build. The lockfile cannot ship
|
|
32
|
+
* with the template: its dependency set exists only once `scaffold` has
|
|
33
|
+
* replaced every `workspace:*` with a published version.
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `workspace:*` with a published version. So it is resolved here, once, against
|
|
38
|
-
* the rewritten `package.json`, and the project commits the result.
|
|
39
|
-
*
|
|
40
|
-
* Best-effort by design: it needs a package manager and a reachable registry,
|
|
41
|
-
* and neither is guaranteed on the machine running the scaffolder. Failure is
|
|
42
|
-
* reported, never thrown — the generated project is perfectly usable without
|
|
43
|
-
* it, the operator just has to run `pnpm install` before `docker build`.
|
|
35
|
+
* Best-effort: it needs a package manager and a reachable registry. Failure is
|
|
36
|
+
* reported, never thrown; the project is usable without the lockfile once the
|
|
37
|
+
* operator runs `pnpm install` before `docker build`.
|
|
44
38
|
*/
|
|
45
39
|
export declare const generateLockfile: (targetDir: string) => LockfileResult;
|
|
46
40
|
/**
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AA2BA
|
|
1
|
+
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AA2BA;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GAAI,cAAc,MAAM,EAAE,QAAQ,MAAM,KAAG,OAI9E,CAAC;AAMF,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IAwCjE,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,cAAc,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAsBnD;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AAyDF;;;;GAIG;AACH,eAAO,MAAM,IAAI,QAAO,IAgEvB,CAAC"}
|
package/dist/index.mjs
CHANGED
|
@@ -23,12 +23,10 @@ const EXCLUDED_DIRS = new Set(["node_modules", "dist"]);
|
|
|
23
23
|
/**
|
|
24
24
|
* Decides whether one entry of the template tree is copied into a new project.
|
|
25
25
|
*
|
|
26
|
-
* The exclusion is judged on the path
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* absolute path therefore rejected the entire template and produced an empty
|
|
31
|
-
* project directory — the scaffolder only ever worked from a source checkout.
|
|
26
|
+
* The exclusion is judged on the path relative to the template root. Run the
|
|
27
|
+
* documented way (`npm create`, `npx`), the scaffolder sits inside
|
|
28
|
+
* `node_modules`, so every absolute source path contains a `node_modules`
|
|
29
|
+
* segment and matching on it would exclude the whole template.
|
|
32
30
|
*/
|
|
33
31
|
export const isTemplateEntryIncluded = (templatesDir, source) => {
|
|
34
32
|
const rel = relative(templatesDir, source);
|
|
@@ -74,11 +72,9 @@ export const scaffold = (targetDir, projectName) => {
|
|
|
74
72
|
const pkgPath = resolve(targetDir, "package.json");
|
|
75
73
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
76
74
|
pkg.name = projectName;
|
|
77
|
-
// `"private": true` is
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// scaffolded service is the rare intent; the operator who has it states it
|
|
81
|
-
// by removing the field.
|
|
75
|
+
// `"private": true` is kept: the scaffold is an authorization service —
|
|
76
|
+
// policy code and config — and the field makes an accidental `npm publish`
|
|
77
|
+
// fail. An operator who means to publish removes it.
|
|
82
78
|
// Replace all workspace:* references with per-package published versions
|
|
83
79
|
const versions = getPackageVersions();
|
|
84
80
|
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
@@ -109,28 +105,21 @@ const LOCKFILE_ARGS = ["install", "--lockfile-only", "--ignore-workspace"];
|
|
|
109
105
|
* covers a machine that only has Node, and honours the `packageManager` field
|
|
110
106
|
* the template ships.
|
|
111
107
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* and a package manager that ran and exited non-zero has already reported that
|
|
117
|
-
* resolution failed — retrying either would hide the real cause behind
|
|
118
|
-
* "package manager missing" and hand the operator the wrong instruction.
|
|
108
|
+
* Only `ENOENT` (the binary is not here) moves on to the next launcher. Any
|
|
109
|
+
* other launch error (`EACCES`, `EPERM`, …) means the binary is there and could
|
|
110
|
+
* not be run, and a non-zero exit means resolution failed; retrying either
|
|
111
|
+
* would hide the real cause behind "package manager missing".
|
|
119
112
|
*/
|
|
120
113
|
const LOCKFILE_LAUNCHERS = [["pnpm"], ["corepack", "pnpm"]];
|
|
121
114
|
/**
|
|
122
|
-
* Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir
|
|
115
|
+
* Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir`, for the
|
|
116
|
+
* template's `pnpm install --frozen-lockfile` build. The lockfile cannot ship
|
|
117
|
+
* with the template: its dependency set exists only once `scaffold` has
|
|
118
|
+
* replaced every `workspace:*` with a published version.
|
|
123
119
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* `workspace:*` with a published version. So it is resolved here, once, against
|
|
128
|
-
* the rewritten `package.json`, and the project commits the result.
|
|
129
|
-
*
|
|
130
|
-
* Best-effort by design: it needs a package manager and a reachable registry,
|
|
131
|
-
* and neither is guaranteed on the machine running the scaffolder. Failure is
|
|
132
|
-
* reported, never thrown — the generated project is perfectly usable without
|
|
133
|
-
* it, the operator just has to run `pnpm install` before `docker build`.
|
|
120
|
+
* Best-effort: it needs a package manager and a reachable registry. Failure is
|
|
121
|
+
* reported, never thrown; the project is usable without the lockfile once the
|
|
122
|
+
* operator runs `pnpm install` before `docker build`.
|
|
134
123
|
*/
|
|
135
124
|
export const generateLockfile = (targetDir) => {
|
|
136
125
|
const attempts = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/create-auth-policy-verifier",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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.6.
|
|
32
|
-
"vitest": "^5.0.
|
|
31
|
+
"@types/node": "^26.6.3",
|
|
32
|
+
"vitest": "^5.0.2"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc",
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
# The base image is pinned by digest, not just by tag:
|
|
2
|
-
#
|
|
3
|
-
# rebuild. The digest is the multi-arch index, so amd64 and arm64 both resolve
|
|
1
|
+
# The base image is pinned by digest, not just by tag: a tag is a moving
|
|
2
|
+
# pointer, so the same source would build a different image on each rebuild. The digest is the multi-arch index, so amd64 and arm64 both resolve
|
|
4
3
|
# from it. Dependabot's `docker` ecosystem keeps the pair in step — bump the
|
|
5
4
|
# tag and the digest together, never the tag alone.
|
|
6
|
-
FROM node:26-alpine@sha256:
|
|
5
|
+
FROM node:26-alpine@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80 AS node-base
|
|
7
6
|
|
|
8
7
|
ENV HOME=/home/node
|
|
9
8
|
|
|
10
|
-
# corepack is pinned too. `npm install -g corepack
|
|
11
|
-
#
|
|
9
|
+
# corepack is pinned too. An unpinned `npm install -g corepack` resolves
|
|
10
|
+
# whatever is latest at build time, a mutable dependency of every build. The
|
|
12
11
|
# pnpm version itself comes from the `packageManager` field in package.json,
|
|
13
12
|
# which corepack reads.
|
|
14
13
|
RUN apk add --no-cache tini \
|
|
@@ -23,7 +22,7 @@ FROM node-base AS deps
|
|
|
23
22
|
# pnpm-lock.yaml is a required build input: `create-auth-policy-verifier`
|
|
24
23
|
# generates it at scaffold time and the project commits it. Without it this
|
|
25
24
|
# COPY fails, which is the intended outcome — a build that silently re-resolves
|
|
26
|
-
# every dependency range is
|
|
25
|
+
# every dependency range is what requiring it prevents.
|
|
27
26
|
COPY --chown=node:node package.json pnpm-lock.yaml ./
|
|
28
27
|
|
|
29
28
|
USER node
|
|
@@ -76,7 +75,7 @@ EXPOSE ${HTTP_PORT}
|
|
|
76
75
|
|
|
77
76
|
# Probes the container's own routable address, NOT 127.0.0.1 — deliberately.
|
|
78
77
|
#
|
|
79
|
-
# `application.conf` binds loopback by default
|
|
78
|
+
# `application.conf` binds loopback by default, because /verify answers
|
|
80
79
|
# with an authorization decision and a reachable port is a decision oracle. A
|
|
81
80
|
# container that keeps that default is reachable from nothing: the published
|
|
82
81
|
# port never connects. A probe against 127.0.0.1 passes anyway — inside the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @o3co/auth-policy-verifier-standalone
|
|
2
2
|
|
|
3
|
-
最終更新: 2026-
|
|
3
|
+
最終更新: 2026-10-01
|
|
4
4
|
|
|
5
5
|
auth.policy-verifier のデプロイ可能なサーバーテンプレートです。このパッケージはコンポジションルートとして機能し、設定の読み込み・モジュールのロード・Express サーバーの起動を担います。`@o3co/create-auth-policy-verifier` によって生成されます。
|
|
6
6
|
|
|
@@ -77,10 +77,10 @@ OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
|
|
|
77
77
|
| `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | audience を読む claim。`azp`(Clerk)、`client_id`(Cognito)。`aud` 以外を指定すると `aud` は一切見ない |
|
|
78
78
|
| `OAUTH_AUTHENTICATOR` | `jwt` | subject を確立する token authenticator。他の名前は `main.mts` で module が登録したものでなければならない |
|
|
79
79
|
| `OAUTH_JWT_MODE` | `verify` | `verify` はトークンを完全検証。明示的な `insecure-decode`(テスト専用)は署名検証なしでデコードする — `exp`/`nbf` は引き続き強制される |
|
|
80
|
-
| `RULE_ON_EMPTY_RULE_SET` | `deny` |
|
|
80
|
+
| `RULE_ON_EMPTY_RULE_SET` | `deny` | 許可するルールが 1 つも集まらなかったときの決定(`deny` \| `allow`) |
|
|
81
81
|
| `VERIFY_MAX_BATCH_SIZE` | `50` | `POST /verify/batch` の件数上限 |
|
|
82
82
|
| `VERIFY_CREDENTIAL_TO_COLLECTORS` | `never` | `expose` は生の資格情報を `context.credential` として collector に渡す — subject として下流 API を呼ぶ collector のためだけの設定。既定では collector は検証済みクレームだけを扱う。資格情報はリプレイ可能であり、context がログに出ればそれが漏れる |
|
|
83
|
-
| `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` にすると、policy を背後に持つ各 Rule の `evaluation`(status
|
|
83
|
+
| `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` にすると、policy を背後に持つ各 Rule の `evaluation`(status、評価した policy revision(#244)、完了した answer ではそれを決めた policy(#199))を決定の response に載せる。どの revision が操作を許可したかを記録する呼び出し側サービス向け。`decision` ログイベントにはどちらの設定でも載る。受理される token の保持者全員に「policy set がいつ変わったか」「各 answer をどの policy が決めたか」を伝えることになるので、それが問題になる環境では `HTTP_CALLER_AUTH_TOKEN` と併用する |
|
|
84
84
|
| `LOG_LEVEL` | `info` | 出力する最低レベル: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`。decision ログのスイッチも兼ねる — [可観測性](#可観測性)を参照 |
|
|
85
85
|
|
|
86
86
|
## HS256 シークレットの強度
|
|
@@ -157,7 +157,7 @@ Authorization: Bearer <jwt>
|
|
|
157
157
|
| `resource` / `action` | 呼び出し元が送ったそのままの値 |
|
|
158
158
|
| `decision` | `allow` または `deny` |
|
|
159
159
|
| `code` | deny のみ — 呼び出し元がワイヤ上で受け取ったコードと同じ |
|
|
160
|
-
| `satisfiedBy` | allow のみ —
|
|
160
|
+
| `satisfiedBy` | allow のみ — 許可する各グループを満たしたルールの `{ruleType, code}`。`onEmptyRuleSet = "allow"` が通したときは空 |
|
|
161
161
|
| `deniedBy` | deny のみ — 最初に失敗したグループと、そこで拒否した全代替ルール |
|
|
162
162
|
| `requestId` | 呼び出し元が送った `x-request-id`(サーバーが受け入れる形のもの。[失敗イベント](#失敗イベント) を参照)。無い場合は省略される |
|
|
163
163
|
| `durationMs` | Collector パイプラインと evaluator に費やした時間。HTTP の往復時間ではない |
|
|
@@ -193,11 +193,11 @@ Authorization: Bearer <jwt>
|
|
|
193
193
|
| `collector_threw` | コレクターが reject / throw した |
|
|
194
194
|
| `attribute_conflict` | 2 つの attribute コレクターが同じスカラーキーに異なる値を書いた |
|
|
195
195
|
| `rule_timeout` | 非同期ルールが予算を、またはルールフェーズがデッドラインを超えた |
|
|
196
|
-
| `rule_threw` | ルールの `verify` が throw
|
|
196
|
+
| `rule_threw` | ルールの `verify` が throw した、`decide` が reject した、またはその `ruleType` のグループに絞るルールと許可するルールが混在した |
|
|
197
197
|
| `body_rejected` | JSON body parser が、deny エンベロープが 4xx に対応付けていない形で失敗した |
|
|
198
198
|
| `internal` | decision 自身の collect や評価から出てきたのではないもの — throw した resource parser や authenticator(何を throw したかによらず) |
|
|
199
199
|
|
|
200
|
-
この集合は閉じています: アラートとフィルタは `err.message` ではなく `category` の等値に対して張ってください。`collector` は `config/application.conf` の `attribute.collectors` / `rule.collectors` 内のエントリ位置とクラス名です。JWKS に到達できないことは category ではありません — `
|
|
200
|
+
この集合は閉じています: アラートとフィルタは `err.message` ではなく `category` の等値に対して張ってください。`collector` は `config/application.conf` の `attribute.collectors` / `rule.collectors` 内のエントリ位置とクラス名です。JWKS に到達できないことは category ではありません — 判定の前に `503 verification_unavailable` で応答され、`jwt_verification_unavailable` としてログに出ます(後述)。
|
|
201
201
|
|
|
202
202
|
サーバーがこれらの行に加えるものは、トークン・クレーム・`context` のいずれも含みません。collector はその decision についてコレクターのランナーが記録したもので、エラーから読んだ名前ではなく、何も記録されていないタイムアウトは `collector: "unattributed"` です。ルールの `ruleType` と `code` はルールコレクターがリクエストごとにルールを組み立てうるため、識別子の形(英字で始まり、英数字・`_`・`.`・`-` が続く 64 文字以内)の場合だけ載り、それ以外は `redacted` になります。リクエスト ID は検証済みです。`err` は throw されたエラーそのものですが、例外が 1 つあります: core が定義する 3 つの deny エラー(`CollectorTimeoutError`、`RuleTimeoutError`、`AttributeConflictError`)はメッセージと自身のフィールドにコレクター・ルール・属性キーを含むため、分類されたコレクターやルール、同じ識別子の形に制限した属性キー、ヘッダ行だけの stack で組み立て直したものを記録します。それ以外のエラーのメッセージは、その作者の責任です。
|
|
203
203
|
|
|
@@ -251,7 +251,7 @@ scrape_configs:
|
|
|
251
251
|
|
|
252
252
|
`HTTP_PATH_PREFIX` は他のエンドポイントと同様にこのパスも動かします。`HTTP_PATH_PREFIX=/pdp` なら `/pdp/metrics` になり、scrape config の `metrics_path` も合わせる必要があります。
|
|
253
253
|
|
|
254
|
-
**まだ公開していないもの:** 依存先ごとの `up` ゲージ(auth.provider はバックエンドストア向けに持っています)。ここでの相当物は JWKS エンドポイントですが、サンプリング対象になる readiness probe のレジストリがありません。依存先の一覧を別途手で持つゲージは、auth.provider が probe をサンプリングすることで避けた drift そのものです。そのレジストリができるまで、JWKS 障害は `jwt_verification_unavailable` ログイベントとして見えます — アラートを張れるようにこれは `error` で出力されています。
|
|
254
|
+
**まだ公開していないもの:** 依存先ごとの `up` ゲージ(auth.provider はバックエンドストア向けに持っています)。ここでの相当物は JWKS エンドポイントですが、サンプリング対象になる readiness probe のレジストリがありません。依存先の一覧を別途手で持つゲージは、auth.provider が probe をサンプリングすることで避けた drift そのものです。そのレジストリができるまで、JWKS 障害は `jwt_verification_unavailable` ログイベントとして見えます — アラートを張れるようにこれは `error` で出力されています。HTTP リクエストのヒストグラムにも `503` の応答として現れます。
|
|
255
255
|
|
|
256
256
|
## デフォルトコレクター
|
|
257
257
|
|
|
@@ -289,7 +289,7 @@ resource parser を 1 つ(`DotNotationResourceParser`)登録します。各
|
|
|
289
289
|
リクエスト対象の resource と action に対して `<action>:<resourceType>` を bearer
|
|
290
290
|
トークンが持っているときにちょうど許可されます。スコープを発行する IdP があれば
|
|
291
291
|
認可ストアを別途立てなくてもそのまま機能し、かつ fail-closed です — スコープを
|
|
292
|
-
|
|
292
|
+
持たないトークンや誤ったスコープは拒否され、許可するルールが 1 つも集まらなかった
|
|
293
293
|
リクエストも拒否されます(`rule.onEmptyRuleSet = "deny"`)。
|
|
294
294
|
|
|
295
295
|
ルールは種類ごとにグループ化され、**すべてのグループが通る必要があります**。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @o3co/auth-policy-verifier-standalone
|
|
2
2
|
|
|
3
|
-
Last updated: 2026-
|
|
3
|
+
Last updated: 2026-10-01
|
|
4
4
|
|
|
5
5
|
Deployable server template for auth.policy-verifier. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-policy-verifier`.
|
|
6
6
|
|
|
@@ -79,10 +79,10 @@ Individual values can also be overridden with environment variables.
|
|
|
79
79
|
| `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | Claim the audience is read from: `azp` (Clerk), `client_id` (Cognito). Anything but `aud` means `aud` is not consulted at all |
|
|
80
80
|
| `OAUTH_AUTHENTICATOR` | `jwt` | Which token authenticator establishes the subject; another name must be registered by a module in `main.mts` |
|
|
81
81
|
| `OAUTH_JWT_MODE` | `verify` | `verify` fully verifies tokens; the explicit `insecure-decode` (test-only) decodes without signature verification — `exp`/`nbf` are still enforced |
|
|
82
|
-
| `RULE_ON_EMPTY_RULE_SET` | `deny` | Decision when no rule is collected (`deny` \| `allow`) |
|
|
82
|
+
| `RULE_ON_EMPTY_RULE_SET` | `deny` | Decision when no granting rule is collected (`deny` \| `allow`) |
|
|
83
83
|
| `VERIFY_MAX_BATCH_SIZE` | `50` | Cap on `POST /verify/batch` entries |
|
|
84
84
|
| `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 |
|
|
85
|
-
| `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` puts each policy-backed rule's `evaluation` — its status
|
|
85
|
+
| `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` puts each policy-backed rule's `evaluation` — its status, the policy revision it evaluated (#244) and, for a completed answer, the policies that determined it (#199) — on the decision response, for a calling service that records which revision authorized an operation. The `decision` log event carries it either way. It tells any holder of an accepted token when the policy set changed and which policies determined each answer, so pair it with `HTTP_CALLER_AUTH_TOKEN` where that matters |
|
|
86
86
|
| `LOG_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Also the switch for the decision log — see [Observability](#observability) |
|
|
87
87
|
|
|
88
88
|
## HS256 secret strength
|
|
@@ -159,7 +159,7 @@ Every decision emits one structured event named `decision`, at `info`:
|
|
|
159
159
|
| `resource` / `action` | Exactly as the caller sent them |
|
|
160
160
|
| `decision` | `allow` or `deny` |
|
|
161
161
|
| `code` | Deny only — the same code the caller got on the wire |
|
|
162
|
-
| `satisfiedBy` | Allow only — the `{ruleType, code}` of the rule that satisfied each group |
|
|
162
|
+
| `satisfiedBy` | Allow only — the `{ruleType, code}` of the rule that satisfied each granting group; empty when `onEmptyRuleSet = "allow"` let the request through |
|
|
163
163
|
| `deniedBy` | Deny only — the first failing group, and every alternative in it that refused |
|
|
164
164
|
| `requestId` | `x-request-id` as sent, when the caller sent one the server carries (see [Failure events](#failure-events)). Omitted otherwise |
|
|
165
165
|
| `durationMs` | Time in the collector pipelines and the evaluator — not the HTTP round trip |
|
|
@@ -195,11 +195,11 @@ A decision that could not be made is logged at `error` (level 50), and the line
|
|
|
195
195
|
| `collector_threw` | A collector rejected or threw |
|
|
196
196
|
| `attribute_conflict` | Two attribute collectors wrote different values to one scalar key |
|
|
197
197
|
| `rule_timeout` | An asynchronous rule overran its budget, or the rule phase its deadline |
|
|
198
|
-
| `rule_threw` | A rule's `verify` threw or its `decide` rejected |
|
|
198
|
+
| `rule_threw` | A rule's `verify` threw or its `decide` rejected, or its `ruleType` group mixed restricting and granting rules |
|
|
199
199
|
| `body_rejected` | The JSON body parser failed in a way the deny envelope does not map to a 4xx |
|
|
200
200
|
| `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 |
|
|
201
201
|
|
|
202
|
-
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 `
|
|
202
|
+
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 `503 verification_unavailable` before any decision, and logged as `jwt_verification_unavailable` (see below).
|
|
203
203
|
|
|
204
204
|
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.
|
|
205
205
|
|
|
@@ -253,7 +253,7 @@ scrape_configs:
|
|
|
253
253
|
|
|
254
254
|
`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.
|
|
255
255
|
|
|
256
|
-
**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.
|
|
256
|
+
**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, and as `503` responses on the HTTP request histogram.
|
|
257
257
|
|
|
258
258
|
## Default Collectors
|
|
259
259
|
|
|
@@ -293,7 +293,7 @@ else: a request is allowed exactly when the bearer token carries
|
|
|
293
293
|
`<action>:<resourceType>` for the resource and action it asks about. It is
|
|
294
294
|
functional against any issuer that mints scopes, with no authorization store to
|
|
295
295
|
stand up first, and it is fail-closed — a token with no scope, or the wrong one,
|
|
296
|
-
is denied, and so is a request that collects no rule
|
|
296
|
+
is denied, and so is a request that collects no granting rule
|
|
297
297
|
(`rule.onEmptyRuleSet = "deny"`).
|
|
298
298
|
|
|
299
299
|
Rules are grouped by kind and **every group must pass**. A rule collector enabled
|
|
@@ -199,7 +199,9 @@ attribute {
|
|
|
199
199
|
collectors = [
|
|
200
200
|
# The token's `scope` claim, split into the list the scope rule reads.
|
|
201
201
|
# `claim = "scp"` reads Okta's scope array instead; pair it with the same
|
|
202
|
-
# `claim` on ResourceActionScopeRuleCollector below
|
|
202
|
+
# `claim` on ResourceActionScopeRuleCollector below, which reads that claim
|
|
203
|
+
# to tell whether the token asserted scopes at all — nothing checks that
|
|
204
|
+
# the two names agree.
|
|
203
205
|
{ collector = "PayloadScopeCollector" }
|
|
204
206
|
# The token's `sub`, which becomes the `subject` on the decision response
|
|
205
207
|
# and the `sub` field on the audit log line.
|
|
@@ -207,13 +209,14 @@ attribute {
|
|
|
207
209
|
]
|
|
208
210
|
# Deliberately absent: anything supplying permissions or roles. This
|
|
209
211
|
# deployment authorizes from the token's scopes alone. Adding a permission
|
|
210
|
-
# rule means adding its supplier here in the same edit
|
|
212
|
+
# rule means adding its supplier here in the same edit.
|
|
211
213
|
}
|
|
212
214
|
|
|
213
215
|
rule {
|
|
214
|
-
# Decision for a request that collects no rule
|
|
215
|
-
# keeps the verifier fail-closed; "allow" is an explicit opt-out and
|
|
216
|
-
# any request that
|
|
216
|
+
# Decision for a request that collects no granting rule. "deny" (the
|
|
217
|
+
# default) keeps the verifier fail-closed; "allow" is an explicit opt-out and
|
|
218
|
+
# makes any request that collects no granting rule succeed, provided it
|
|
219
|
+
# passes the restricting rules it collected (a delegated token's range, say).
|
|
217
220
|
onEmptyRuleSet = "deny"
|
|
218
221
|
onEmptyRuleSet = ${?RULE_ON_EMPTY_RULE_SET}
|
|
219
222
|
|
|
@@ -221,12 +224,12 @@ rule {
|
|
|
221
224
|
# request is allowed exactly when the token carries "<action>:<resourceType>".
|
|
222
225
|
# It works against any issuer that mints scopes without an authorization store
|
|
223
226
|
# to stand up first, and it stays fail-closed — a token carrying no scope, or
|
|
224
|
-
# the wrong one, is denied, as is a request that collects no rule
|
|
227
|
+
# the wrong one, is denied, as is a request that collects no granting rule.
|
|
225
228
|
#
|
|
226
229
|
# Rules are grouped by kind and every GROUP must pass, so one group nothing can
|
|
227
230
|
# satisfy denies every request the deployment ever sees. That is what enabling
|
|
228
231
|
# a rule collector without the attribute collector feeding it does, and it is
|
|
229
|
-
# why the permission rule below is off rather than merely unconfigured
|
|
232
|
+
# why the permission rule below is off rather than merely unconfigured.
|
|
230
233
|
collectors = [
|
|
231
234
|
# Scope matching is exact and case-sensitive (RFC 6749 §3.3): the token must
|
|
232
235
|
# carry "<action>:<resourceType>" verbatim. If your issuer emits bare resource
|
|
@@ -328,7 +331,7 @@ verify {
|
|
|
328
331
|
# the concurrency cap does not spend it.
|
|
329
332
|
collectorTimeoutMs = 2000
|
|
330
333
|
collectorTimeoutMs = ${?VERIFY_COLLECTOR_TIMEOUT_MS}
|
|
331
|
-
# How long one asynchronous rule (an out-of-process policy engine
|
|
334
|
+
# How long one asynchronous rule (an out-of-process policy engine) may
|
|
332
335
|
# take to answer. Same budget and ceiling as one collector; overrun = deny.
|
|
333
336
|
ruleTimeoutMs = 2000
|
|
334
337
|
ruleTimeoutMs = ${?VERIFY_RULE_TIMEOUT_MS}
|
|
@@ -361,8 +364,8 @@ verify {
|
|
|
361
364
|
batchConcurrency = 8
|
|
362
365
|
batchConcurrency = ${?VERIFY_BATCH_CONCURRENCY}
|
|
363
366
|
|
|
364
|
-
# Whether collectors receive the raw credential as `context.credential
|
|
365
|
-
#
|
|
367
|
+
# Whether collectors receive the raw credential as `context.credential`.
|
|
368
|
+
# "never" (the default): collectors get verified claims only — the
|
|
366
369
|
# credential is replayable, and a collector that logs its context would leak
|
|
367
370
|
# a live token. Set "expose" ONLY when a project-side collector calls a
|
|
368
371
|
# downstream API as the subject (token forwarding / exchange); this line is
|
|
@@ -371,13 +374,16 @@ verify {
|
|
|
371
374
|
credentialToCollectors = ${?VERIFY_CREDENTIAL_TO_COLLECTORS}
|
|
372
375
|
|
|
373
376
|
# Whether a decision RESPONSE carries what each policy-backed rule reported
|
|
374
|
-
# about the evaluation behind its answer — its status,
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
# when the
|
|
380
|
-
#
|
|
377
|
+
# about the evaluation behind its answer — its status, the policy revision it
|
|
378
|
+
# evaluated and, for a completed answer, the policies that determined it.
|
|
379
|
+
# The `decision` log event always carries it; this is only about the wire.
|
|
380
|
+
# "omit" (the default): no rule outcome in the response carries an
|
|
381
|
+
# `evaluation` key.
|
|
382
|
+
# Set "include" when the calling service records which policy revision
|
|
383
|
+
# authorized an operation. It tells any holder of an accepted token when the
|
|
384
|
+
# policy set changed, whether a denial was a policy's or the engine failing,
|
|
385
|
+
# and which policies determined each answer, so pair it with
|
|
386
|
+
# `http.callerAuth` where that matters.
|
|
381
387
|
evaluationInResponse = "omit"
|
|
382
388
|
evaluationInResponse = ${?VERIFY_EVALUATION_IN_RESPONSE}
|
|
383
389
|
}
|
|
@@ -15,8 +15,9 @@ 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`
|
|
19
|
-
# only when the app composes cedarPolicyModule
|
|
18
|
+
# Where the Cedar http engine finds the `cedar-engine` service below
|
|
19
|
+
# (profile `cedar`) — read only when the app composes cedarPolicyModule
|
|
20
|
+
# without the wasm package.
|
|
20
21
|
# The engine has no default address (a missing one refuses to start,
|
|
21
22
|
# naming this variable and the wasm package), so it is stated here, as an
|
|
22
23
|
# interpolation default: an `environment` entry outranks `env_file`, so a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/auth-policy-verifier-standalone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"packageManager": "pnpm@10.29.3",
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
"pino": "^10.3.1"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@types/node": "^26.6.
|
|
27
|
+
"@types/node": "^26.6.3",
|
|
28
28
|
"@types/supertest": "^7.2.1",
|
|
29
29
|
"jose": "^6.2.12",
|
|
30
|
-
"supertest": "^7.
|
|
30
|
+
"supertest": "^7.3.0",
|
|
31
31
|
"tsx": "^4.23.15",
|
|
32
32
|
"typescript": "^5.9.3",
|
|
33
|
-
"vitest": "^5.0.
|
|
33
|
+
"vitest": "^5.0.2"
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
|
|
|
6
6
|
|
|
7
7
|
const compose = readFileSync(new URL("../../docker-compose.yml", import.meta.url), "utf8");
|
|
8
8
|
|
|
9
|
-
describe("docker-compose.yml — the cedar profile's agent is authenticated
|
|
9
|
+
describe("docker-compose.yml — the cedar profile's agent is authenticated", () => {
|
|
10
10
|
it("starts the agent with the token the app sends", () => {
|
|
11
11
|
// An unauthenticated cedar-agent is a write oracle over the policy set:
|
|
12
12
|
// anything that reaches its port can PUT `permit(principal, action,
|
|
@@ -14,8 +14,7 @@ describe("docker-compose.yml — the cedar profile's agent is authenticated (v0.
|
|
|
14
14
|
// reads, so the two cannot disagree.
|
|
15
15
|
expect(compose).toMatch(/- CEDAR_AGENT_AUTHENTICATION=\$\{CEDAR_AUTHENTICATION:-\}/);
|
|
16
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).
|
|
17
|
+
// a shell variable or `--env-file` would otherwise reach the agent alone.
|
|
19
18
|
expect(compose).toMatch(/- CEDAR_AUTHENTICATION=\$\{CEDAR_AUTHENTICATION:-\}/);
|
|
20
19
|
});
|
|
21
20
|
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* These read the real files under templates/standalone/config/, so they cover
|
|
8
8
|
* the shipped layering rather than a hand-built object: an env overlay that
|
|
9
9
|
* contains only comments is an empty HOCON document, and it must fall back to
|
|
10
|
-
* application.conf instead of failing to parse.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* application.conf instead of failing to parse. The smoke test boots from the
|
|
11
|
+
* development overlay too; only this file asserts what each shipped overlay
|
|
12
|
+
* resolves to.
|
|
13
13
|
*/
|
|
14
14
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
@@ -22,7 +22,7 @@ const configDirPath = fileURLToPath(new URL("../../config/", import.meta.url));
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* 64 hex characters — 32 decoded bytes, the entropy floor the schema enforces
|
|
25
|
-
* on every HS256 secret
|
|
25
|
+
* on every HS256 secret. A shorter value fails to load.
|
|
26
26
|
*/
|
|
27
27
|
const TEST_SECRET = "11".repeat(32);
|
|
28
28
|
|
|
@@ -43,10 +43,7 @@ const envKeys = [
|
|
|
43
43
|
"OAUTH_JWT_CLOCK_TOLERANCE_SECONDS",
|
|
44
44
|
"VERIFY_MAX_BATCH_SIZE",
|
|
45
45
|
// Every VERIFY_* the cases below set has to be listed, or it leaks into the
|
|
46
|
-
// tests after it.
|
|
47
|
-
// set five more, and the leak was invisible for as long as no later case
|
|
48
|
-
// loaded the shipped config and asserted on `verify` — the first one that
|
|
49
|
-
// did got a boot failure from an env var a test three screens up had set.
|
|
46
|
+
// tests after it.
|
|
50
47
|
"VERIFY_MAX_BODY_BYTES",
|
|
51
48
|
"VERIFY_MAX_RESOURCE_LENGTH",
|
|
52
49
|
"VERIFY_MAX_ACTION_LENGTH",
|
|
@@ -82,7 +79,7 @@ describe("loadAppConfig", () => {
|
|
|
82
79
|
|
|
83
80
|
const config = loadAppConfig(configDirPath, env);
|
|
84
81
|
|
|
85
|
-
// Loopback by default
|
|
82
|
+
// Loopback by default: the shipped config is a sidecar config,
|
|
86
83
|
// and reaching the port from another host is an explicit opt-in. The
|
|
87
84
|
// callerAuth block exists but carries no token, which means the gate is off.
|
|
88
85
|
expect(config.http).toEqual({
|
|
@@ -110,7 +107,7 @@ describe("loadAppConfig", () => {
|
|
|
110
107
|
"PayloadScopeCollector",
|
|
111
108
|
"PayloadSubjectIdCollector",
|
|
112
109
|
]);
|
|
113
|
-
// Scope-only, and the two lists are one decision
|
|
110
|
+
// Scope-only, and the two lists are one decision. Rule groups are
|
|
114
111
|
// ANDed, so a rule reading an attribute no collector above produces is a
|
|
115
112
|
// group nothing satisfies — which is a verifier that denies every request.
|
|
116
113
|
// A permission rule may only join this list together with its supplier.
|
|
@@ -137,7 +134,7 @@ describe("loadAppConfig", () => {
|
|
|
137
134
|
});
|
|
138
135
|
});
|
|
139
136
|
|
|
140
|
-
it("enables caller authentication from the environment
|
|
137
|
+
it("enables caller authentication from the environment", () => {
|
|
141
138
|
setRequiredEnv();
|
|
142
139
|
process.env.HTTP_CALLER_AUTH_TOKEN = "caller-secret";
|
|
143
140
|
|
|
@@ -165,7 +162,7 @@ describe("loadAppConfig", () => {
|
|
|
165
162
|
expect(() => loadAppConfig(configDirPath, "development")).toThrow(/callerAuth[.,\s\S]*token/);
|
|
166
163
|
});
|
|
167
164
|
|
|
168
|
-
it("selects insecure-decode mode via OAUTH_JWT_MODE, requiring no key material
|
|
165
|
+
it("selects insecure-decode mode via OAUTH_JWT_MODE, requiring no key material", () => {
|
|
169
166
|
// The value is the consent: only the literal string "insecure-decode"
|
|
170
167
|
// selects the decode-only path, so a stray boolean-ish env value cannot.
|
|
171
168
|
process.env.OAUTH_JWT_MODE = "insecure-decode";
|
|
@@ -175,7 +172,7 @@ describe("loadAppConfig", () => {
|
|
|
175
172
|
expect(config.oauth.jwt?.mode).toBe("insecure-decode");
|
|
176
173
|
});
|
|
177
174
|
|
|
178
|
-
it(
|
|
175
|
+
it('rejects a boolean-ish OAUTH_JWT_MODE such as "false"', () => {
|
|
179
176
|
setRequiredEnv();
|
|
180
177
|
process.env.OAUTH_JWT_MODE = "false";
|
|
181
178
|
|
|
@@ -190,9 +187,9 @@ describe("loadAppConfig", () => {
|
|
|
190
187
|
|
|
191
188
|
it.each([
|
|
192
189
|
["a one-character OAUTH_JWT_SECRET", "s"],
|
|
193
|
-
["
|
|
190
|
+
["a placeholder value", "your-secret"],
|
|
194
191
|
["32 hex characters — 16 decoded bytes", "ab".repeat(16)],
|
|
195
|
-
])("refuses to load %s
|
|
192
|
+
])("refuses to load %s", (_label, secret) => {
|
|
196
193
|
// The floor applies to the shipped config as loaded, not only to
|
|
197
194
|
// hand-built objects: `secret = ${?OAUTH_JWT_SECRET}` is where a weak
|
|
198
195
|
// value actually enters a deployment.
|
|
@@ -209,7 +206,7 @@ describe("loadAppConfig", () => {
|
|
|
209
206
|
});
|
|
210
207
|
});
|
|
211
208
|
|
|
212
|
-
describe("loadAppConfig — RFC 9068 requirements
|
|
209
|
+
describe("loadAppConfig — RFC 9068 requirements", () => {
|
|
213
210
|
it("fails to load when the issuer is not supplied", () => {
|
|
214
211
|
process.env.OAUTH_JWT_SECRET = TEST_SECRET;
|
|
215
212
|
process.env.OAUTH_JWT_AUDIENCE = "https://api.test";
|
|
@@ -225,13 +222,12 @@ describe("loadAppConfig — RFC 9068 requirements (#105)", () => {
|
|
|
225
222
|
});
|
|
226
223
|
});
|
|
227
224
|
|
|
228
|
-
describe("loadAppConfig — numeric knobs through the real 3-tier resolution
|
|
229
|
-
// The knobs are read by `resolveBound` at both boundaries
|
|
230
|
-
// path that proves the schema half
|
|
225
|
+
describe("loadAppConfig — numeric knobs through the real 3-tier resolution", () => {
|
|
226
|
+
// The knobs are read by `resolveBound` at both boundaries, and this is the
|
|
227
|
+
// path that proves the schema half works where it actually runs: HOCON
|
|
231
228
|
// substitutes `${?VAR}` as a STRING, and the ts.hocon zod adapter's coercion
|
|
232
229
|
// walk only rewrites fields it recognises as numbers. A knob it cannot see
|
|
233
|
-
// through must
|
|
234
|
-
// `resolveBound` does, and what a hand-built config already relied on.
|
|
230
|
+
// through must coerce the string itself, which `resolveBound` does.
|
|
235
231
|
|
|
236
232
|
it("takes the numbers application.conf states when nothing overrides them", () => {
|
|
237
233
|
setRequiredEnv();
|
|
@@ -245,7 +241,7 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
|
|
|
245
241
|
expect(config.oauth.jwt?.maxTokenAgeSeconds).toBe(86_400);
|
|
246
242
|
expect(config.oauth.jwt?.clockToleranceSeconds).toBe(0);
|
|
247
243
|
expect(config.verify.maxBatchSize).toBe(50);
|
|
248
|
-
// The request limits
|
|
244
|
+
// The request limits travel the same path.
|
|
249
245
|
expect(config.verify.maxBodyBytes).toBe(65_536);
|
|
250
246
|
expect(config.verify.maxResourceLength).toBe(512);
|
|
251
247
|
expect(config.verify.maxActionLength).toBe(64);
|
|
@@ -341,21 +337,18 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
|
|
|
341
337
|
});
|
|
342
338
|
});
|
|
343
339
|
|
|
344
|
-
describe("loadAppConfig — a config with no verify block at all
|
|
340
|
+
describe("loadAppConfig — a config with no verify block at all", () => {
|
|
345
341
|
/*
|
|
346
342
|
* The shipped `application.conf` writes out every `verify` knob, so loading
|
|
347
343
|
* it exercises the schema's per-key defaults and never the block-level one.
|
|
348
|
-
* The block-level default
|
|
349
|
-
*
|
|
350
|
-
* deployment that has no opinion about batch sizes or collector deadlines
|
|
351
|
-
* simply never writes a `verify` block.
|
|
344
|
+
* The block-level default serves the common shape: an overlay config that
|
|
345
|
+
* repeats only the sections it changes and never writes a `verify` block.
|
|
352
346
|
*
|
|
353
347
|
* zod takes `.default(() => ({…}))` verbatim rather than parsing it back
|
|
354
|
-
* through the shape, so a knob missing from that literal is
|
|
355
|
-
* exactly this shape
|
|
356
|
-
* proved here through the real loader
|
|
357
|
-
* `
|
|
358
|
-
* write a `verify` block and would go on passing.
|
|
348
|
+
* through the shape, so a knob missing from that literal is silently
|
|
349
|
+
* `undefined` for exactly this shape. The schema unit tests all write a
|
|
350
|
+
* `verify` block, so it is proved here through the real loader
|
|
351
|
+
* (`parseFile` → `withFallback` → `validate`).
|
|
359
352
|
*/
|
|
360
353
|
const withoutVerify = (): string => {
|
|
361
354
|
const dir = mkdtempSync(path.join(tmpdir(), "policy-verifier-config-"));
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
* The composition root's logger
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* returns a pino instance (newline-delimited JSON on stdout, the shape every
|
|
11
|
-
* log aggregator ingests without a parser), which satisfies the port
|
|
12
|
-
* structurally and honours `logging.level` from the application config.
|
|
5
|
+
* The composition root's logger. `createAppLogger` returns a pino instance
|
|
6
|
+
* (newline-delimited JSON on stdout, which log aggregators ingest without a
|
|
7
|
+
* parser) that satisfies the `Logger` port `createApp` accepts structurally,
|
|
8
|
+
* so the server's failure events reach it, and that honours `logging.level`
|
|
9
|
+
* from the application config.
|
|
13
10
|
*/
|
|
14
11
|
import type { Logger } from "@o3co/auth.policy-verifier.core";
|
|
15
12
|
import { AppConfigSchema } from "@o3co/auth.policy-verifier.server";
|
|
@@ -2,20 +2,10 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* move it: **there was no force-close deadline**. `server.close()` waits for
|
|
10
|
-
* in-flight requests indefinitely, so one stuck decision meant the process
|
|
11
|
-
* never exited on its own and the orchestrator's SIGKILL took it down
|
|
12
|
-
* mid-flight. Its cleanup-failure path also wrote to `console.error`, a bare
|
|
13
|
-
* line in a composition root whose every other line is NDJSON (#107), and
|
|
14
|
-
* every exit was zero.
|
|
15
|
-
*
|
|
16
|
-
* The behaviour lives in the template now, with a deadline and the app's own
|
|
17
|
-
* logger, and these tests are the contract that was missing. `auth.provider`'s
|
|
18
|
-
* standalone template made the same move in its issue #290.
|
|
5
|
+
* The contract of the template's graceful shutdown (`shutdown.ts`): a
|
|
6
|
+
* force-close deadline, since `server.close()` waits for in-flight requests
|
|
7
|
+
* indefinitely; failures logged through the app's own logger; and a non-zero
|
|
8
|
+
* exit whenever the shutdown was not clean.
|
|
19
9
|
*/
|
|
20
10
|
import type { Server } from "node:http";
|
|
21
11
|
import type { Logger } from "@o3co/auth.policy-verifier.core";
|
|
@@ -190,9 +180,9 @@ describe("installGracefulShutdown", () => {
|
|
|
190
180
|
});
|
|
191
181
|
|
|
192
182
|
it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
183
|
+
// The drain deadline is already cleared when cleanup runs, so only the
|
|
184
|
+
// cleanup budget stands between a dispose that never settles and an
|
|
185
|
+
// `exit` that is never reached.
|
|
196
186
|
vi.useFakeTimers();
|
|
197
187
|
try {
|
|
198
188
|
const { signals, finishDraining, exit, logger } = install({
|
|
@@ -228,7 +218,7 @@ describe("installGracefulShutdown", () => {
|
|
|
228
218
|
}
|
|
229
219
|
});
|
|
230
220
|
|
|
231
|
-
it("defers the real exit a turn so pino's buffered destination can flush
|
|
221
|
+
it("defers the real exit a turn so pino's buffered destination can flush", async () => {
|
|
232
222
|
// `createAppLogger` uses pino's default (non-synchronous) destination, so
|
|
233
223
|
// exiting in the same tick as the last `logger.error` can drop exactly the
|
|
234
224
|
// `cleanup failed` line an operator would go looking for.
|
|
@@ -240,9 +230,9 @@ describe("installGracefulShutdown", () => {
|
|
|
240
230
|
});
|
|
241
231
|
|
|
242
232
|
it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
|
|
243
|
-
// `
|
|
244
|
-
//
|
|
245
|
-
// carried
|
|
233
|
+
// `reason` names what decided the exit code, so the line an operator
|
|
234
|
+
// alerts on never says "drained" beside a non-zero code. The drain
|
|
235
|
+
// outcome is carried under its own key.
|
|
246
236
|
const { signals, finishDraining, logger, exit } = install({
|
|
247
237
|
cleanup: () => Promise.reject(new Error("teardown failed")),
|
|
248
238
|
});
|
|
@@ -6,18 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* These tests boot the app from `config/application.conf` — the file the image
|
|
8
8
|
* ships and an operator deploys — read through `loadAppConfig`, the same
|
|
9
|
-
* function `main.mts` calls. Nothing here restates the policy:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* That is the point of #113. The suite used to assemble its own config, minus
|
|
13
|
-
* the one collector that made the shipped file deny every request, and stayed
|
|
14
|
-
* green while the product was non-functional. A config the tests hand-write can
|
|
15
|
-
* agree with `application.conf` only by vigilance, and it stopped agreeing.
|
|
9
|
+
* function `main.mts` calls. Nothing here restates the policy: a config the
|
|
10
|
+
* tests hand-wrote could disagree with the shipped file and stay green.
|
|
16
11
|
*
|
|
17
12
|
* The three values the file deliberately does not carry — the HS256 secret, the
|
|
18
13
|
* issuer and the audience — come from the environment here exactly as they do
|
|
19
|
-
* in a deployment
|
|
20
|
-
* supplying them is loading the shipped config, not editing it.
|
|
14
|
+
* in a deployment: a credential is never written in a config file.
|
|
21
15
|
*/
|
|
22
16
|
import { fileURLToPath } from "node:url";
|
|
23
17
|
import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
|
|
@@ -29,7 +23,7 @@ import request from "supertest";
|
|
|
29
23
|
import { describe, expect, it } from "vitest";
|
|
30
24
|
import { loadAppConfig } from "../loadConfig.js";
|
|
31
25
|
|
|
32
|
-
/** 64 hex characters — 32 decoded bytes, the entropy floor
|
|
26
|
+
/** 64 hex characters — 32 decoded bytes, the entropy floor the config schema enforces. */
|
|
33
27
|
const JWT_SECRET = "11".repeat(32);
|
|
34
28
|
const secretKey = new TextEncoder().encode(JWT_SECRET);
|
|
35
29
|
const ISSUER = "https://issuer.test";
|
|
@@ -42,7 +36,7 @@ async function signToken(payload: Record<string, unknown>): Promise<string> {
|
|
|
42
36
|
new SignJWT(payload)
|
|
43
37
|
.setProtectedHeader({ alg: "HS256", typ: "at+jwt" })
|
|
44
38
|
.setIssuedAt()
|
|
45
|
-
// iat and exp are both mandatory
|
|
39
|
+
// iat and exp are both mandatory.
|
|
46
40
|
.setExpirationTime("1h")
|
|
47
41
|
.setIssuer(ISSUER)
|
|
48
42
|
.setAudience(AUDIENCE)
|
|
@@ -62,17 +56,12 @@ const DEPLOYMENT_ENV = {
|
|
|
62
56
|
* overlay resolved against `config/application.conf`, with the deployment's
|
|
63
57
|
* three values in the environment.
|
|
64
58
|
*
|
|
65
|
-
* `process.env` is process-wide,
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* The window is nil as well as tidy: every step here is synchronous —
|
|
73
|
-
* `loadAppConfig` parses and validates without awaiting — and this runs at module
|
|
74
|
-
* scope, so no other file's code can be scheduled between the assignment and the
|
|
75
|
-
* restore.
|
|
59
|
+
* `process.env` is process-wide, so each variable's prior value is
|
|
60
|
+
* **restored**, not deleted — and "was absent" is restored as absent rather
|
|
61
|
+
* than as `""`, a different state this config distinguishes (an exported-empty
|
|
62
|
+
* credential is refused rather than read as "unset"). Every step is synchronous
|
|
63
|
+
* and this runs at module scope, so no other file's code can run between the
|
|
64
|
+
* assignment and the restore.
|
|
76
65
|
*/
|
|
77
66
|
function loadShippedConfig(): AppConfig {
|
|
78
67
|
const saved: Array<[key: string, previous: string | undefined]> = Object.keys(DEPLOYMENT_ENV).map(
|
|
@@ -103,7 +92,7 @@ function createShippedApp(config: AppConfig = shippedConfig) {
|
|
|
103
92
|
describe("standalone smoke", () => {
|
|
104
93
|
it("GET /_healthcheck returns 200", async () => {
|
|
105
94
|
// The liveness path the Dockerfile's HEALTHCHECK probes, and the one every
|
|
106
|
-
// component of the stack answers on
|
|
95
|
+
// component of the stack answers on.
|
|
107
96
|
const app = await createShippedApp();
|
|
108
97
|
|
|
109
98
|
const res = await request(app).get("/_healthcheck");
|
|
@@ -111,8 +100,7 @@ describe("standalone smoke", () => {
|
|
|
111
100
|
});
|
|
112
101
|
|
|
113
102
|
it("GET /healthcheck returns 200 as a compatibility alias", async () => {
|
|
114
|
-
//
|
|
115
|
-
// must keep working.
|
|
103
|
+
// A probe config that names this path must keep working.
|
|
116
104
|
const app = await createShippedApp();
|
|
117
105
|
|
|
118
106
|
const res = await request(app).get("/healthcheck");
|
|
@@ -225,9 +213,9 @@ describe("standalone smoke", () => {
|
|
|
225
213
|
it("POST /verify allows a bare scope once allowBareScopeRewrite is configured", async () => {
|
|
226
214
|
// The one config in this file that is deliberately NOT the shipped one:
|
|
227
215
|
// it exercises the opt-in application.conf documents in a comment beside
|
|
228
|
-
// the collector,
|
|
229
|
-
//
|
|
230
|
-
//
|
|
216
|
+
// the collector, the only path a deployment has to enable the bare-scope
|
|
217
|
+
// rewrite. It is derived from the shipped config rather than written out,
|
|
218
|
+
// so the single key under test is the single thing that differs.
|
|
231
219
|
const rewritingConfig: AppConfig = {
|
|
232
220
|
...shippedConfig,
|
|
233
221
|
rule: {
|
|
@@ -265,8 +253,8 @@ describe("standalone smoke", () => {
|
|
|
265
253
|
});
|
|
266
254
|
|
|
267
255
|
/*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
256
|
+
* The template is the deployable shape, so these assert what an operator of
|
|
257
|
+
* *this* composition actually gets — not what the library can be wired to do.
|
|
270
258
|
*/
|
|
271
259
|
describe("standalone observability", () => {
|
|
272
260
|
it("GET /metrics serves the Prometheus text format with the decision counters", async () => {
|
|
@@ -5,20 +5,18 @@ import type { AppConfig } from "@o3co/auth.policy-verifier.server";
|
|
|
5
5
|
import { pino, stdSerializers } from "pino";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The composition root's logger, injected into `createApp` so the
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
8
|
+
* The composition root's logger, injected into `createApp` so the failure
|
|
9
|
+
* events of the verify router (`verify_internal_error` and the
|
|
10
|
+
* `collector_timeout` / `rule_timeout` / `attribute_conflict` denies) and of
|
|
11
|
+
* the JWT authenticator `createApp` builds (`jwt_token_rejected`,
|
|
12
|
+
* `jwt_verification_unavailable`) reach an aggregator-ready sink.
|
|
11
13
|
*
|
|
12
|
-
* pino, emitting newline-delimited JSON on stdout
|
|
13
|
-
*
|
|
14
|
-
* pino
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* The same wiring exists in the auth.provider standalone template; keeping the
|
|
20
|
-
* two composition roots symmetric is what lets one aggregator pipeline serve
|
|
21
|
-
* the whole stack.
|
|
14
|
+
* pino, emitting newline-delimited JSON on stdout, which log aggregators ingest
|
|
15
|
+
* without a parser. The `Logger` port carries pino's two-overload call
|
|
16
|
+
* signature, so a pino instance satisfies it with no adapter. pino drops
|
|
17
|
+
* sub-threshold calls before formatting, so `logging.level` costs nothing for
|
|
18
|
+
* the levels it excludes. The auth.provider standalone template wires the
|
|
19
|
+
* same, so one aggregator pipeline serves the whole stack.
|
|
22
20
|
*/
|
|
23
21
|
export function createAppLogger(config: AppConfig) {
|
|
24
22
|
return pino({
|
|
@@ -5,27 +5,13 @@ import type { Server } from "node:http";
|
|
|
5
5
|
import type { Logger } from "@o3co/auth.policy-verifier.core";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Graceful shutdown for the scaffolded server.
|
|
8
|
+
* Graceful shutdown for the scaffolded server. It lives in the template rather
|
|
9
|
+
* than a dependency so that "does SIGTERM wait for in-flight decisions, and for
|
|
10
|
+
* how long?" is answerable from the code an operator deploys. auth.provider's
|
|
11
|
+
* standalone template carries the same; keeping the two composition roots
|
|
12
|
+
* symmetric lets one aggregator pipeline serve the whole stack.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. For a
|
|
13
|
-
* component every protected call waits on, "does SIGTERM wait for in-flight
|
|
14
|
-
* decisions, and for how long?" has to be answerable from the code an operator
|
|
15
|
-
* deploys — and it was answerable only by reading a pre-1.0 package no
|
|
16
|
-
* contract here pinned. Reading it answered the question, and the answer was
|
|
17
|
-
* the reason to move it: **there was no deadline**. `server.close()` waits
|
|
18
|
-
* indefinitely, so one stuck decision meant the process never exited on its
|
|
19
|
-
* own and the orchestrator's SIGKILL took it down mid-flight — the opposite of
|
|
20
|
-
* a graceful shutdown, arriving only under the load that produces a stuck
|
|
21
|
-
* request. The cleanup-failure path also wrote to `console.error`, one bare
|
|
22
|
-
* line in a composition root that went to the trouble of wiring NDJSON (#107).
|
|
23
|
-
*
|
|
24
|
-
* `auth.provider`'s standalone template made the same move for the same
|
|
25
|
-
* reasons (o3co/auth.provider#290); keeping the two composition roots
|
|
26
|
-
* symmetric is what lets one aggregator pipeline serve the whole stack.
|
|
27
|
-
*
|
|
28
|
-
* ## The guarantees, stated
|
|
14
|
+
* The guarantees:
|
|
29
15
|
*
|
|
30
16
|
* 1. **SIGTERM and SIGINT** both start it; a second signal is ignored rather
|
|
31
17
|
* than starting a second cleanup over the first one's work.
|
|
@@ -33,21 +19,20 @@ import type { Logger } from "@o3co/auth.policy-verifier.core";
|
|
|
33
19
|
* sockets are released (`closeIdleConnections`) — they hold the server open
|
|
34
20
|
* with no request behind them, so a quiet server would otherwise wait out
|
|
35
21
|
* the whole deadline for nothing.
|
|
36
|
-
* 3. **In-flight requests get `drainTimeoutMs`** (default 10s) to finish
|
|
22
|
+
* 3. **In-flight requests get `drainTimeoutMs`** (default 10s) to finish;
|
|
23
|
+
* `server.close()` alone waits indefinitely.
|
|
37
24
|
* 4. **Past the deadline, remaining connections are cut**
|
|
38
|
-
* (`closeAllConnections`) and the process exits **non-zero
|
|
39
|
-
* orchestrator
|
|
40
|
-
* that ran out of time.
|
|
25
|
+
* (`closeAllConnections`) and the process exits **non-zero**, so an
|
|
26
|
+
* orchestrator can tell a clean drain from one that ran out of time.
|
|
41
27
|
* 5. **`cleanup` runs after draining, before exit**, logged through the app
|
|
42
28
|
* logger and reflected in the exit code. It is bounded by
|
|
43
29
|
* `cleanupTimeoutMs`, so a dispose that never settles cannot wedge the
|
|
44
|
-
* process
|
|
30
|
+
* process.
|
|
45
31
|
* 6. **A `close` that fails is not reported as a clean drain.**
|
|
46
32
|
*
|
|
47
33
|
* Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
|
|
48
34
|
* (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
|
|
49
|
-
* both 30s by default)
|
|
50
|
-
* arrives on someone else's.
|
|
35
|
+
* both 30s by default), so the process closes on its own terms before SIGKILL.
|
|
51
36
|
*/
|
|
52
37
|
export interface GracefulShutdownOptions {
|
|
53
38
|
readonly logger: Logger;
|
package/templates/versions.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth.policy-verifier.core": "0.
|
|
3
|
-
"@o3co/auth.policy-verifier.builtins": "0.
|
|
4
|
-
"@o3co/auth.policy-verifier.server": "0.
|
|
2
|
+
"@o3co/auth.policy-verifier.core": "0.16.0",
|
|
3
|
+
"@o3co/auth.policy-verifier.builtins": "0.16.0",
|
|
4
|
+
"@o3co/auth.policy-verifier.server": "0.16.0"
|
|
5
5
|
}
|