@o3co/auth.policy-verifier.builtins 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -40,6 +40,31 @@ npm install @o3co/auth.policy-verifier.builtins
40
40
 
41
41
  この宣言が信頼境界です — [docs/extending.ja.md](../../docs/extending.ja.md#信頼境界-requestcontext-は呼び出し側のもの) が説明する境界の、既製の守り方がこのコレクターです。`requestContext` は自由形式かつ未検証なので、**宣言していないフィールドは attribute になりません**。dot path は own property のみを辿るため `constructor.name` のような指定は何も読みません。このコレクター自身は語彙を持ち込みません — フィールド名もキー名も運用者が決めるため、[AGENTS.md — Core Vocabulary Scope](../../AGENTS.md#core-vocabulary-scope) の方針を崩さずに実用的なものを提供できます。read-check-write を超える処理(値の導出、外部ストア参照など)が必要な場合は、同節が説明するプロジェクト側の `AttributeCollector` を書いてください。
42
42
 
43
+ #### 予約済みの語彙は書き込み先にできません
44
+
45
+ マッピングの `to` に**予約済み属性キー**を指定することはできません。指定した場合は**構成エラーとしてコンストラクタで拒否**されるため、そう書いたデプロイは最初のリクエストではなく起動時に失敗します:
46
+
47
+ ```hocon
48
+ # 起動時に拒否される — core 自身の語彙
49
+ { from = "groups", to = "scopes" }
50
+
51
+ # 起動時に拒否される — @o3co/auth.policy-verifier.cedar の語彙。
52
+ # 同パッケージがロードされた時点で予約される
53
+ { from = "rid", to = "requestResourceId" }
54
+
55
+ # 問題なし — フィールド名は自由。予約されているのは attribute キーだけ
56
+ { from = "groups", to = "requestGroups", type = "string[]" }
57
+ { from = "scopes", to = "requestedScopes", type = "string[]" }
58
+ ```
59
+
60
+ 予約集合は**このパッケージが保持するリストではありません**。core のレジストリ(`RESERVED_ATTRIBUTE_KEYS` / `reserveAttributeKeys` / `attributeKeyReservation`)です。core は自身の 5 キー — `scopes`、`permissions`、`roles`、`userId`、`clientId` — を予約し、属性語彙を持つ各パッケージはモジュールロード時に自分の語彙を予約します。`@o3co/auth.policy-verifier.cedar` は `requestAction` / `requestResourceType` / `requestResourceId` / `requestResourceRaw` を予約します。プロジェクト側の Collector も、書き込むキーを同じ方法で予約してください。設定でそのパッケージの Collector 名を書ける構成は、すでにそのパッケージを import しているため、このコレクターが構築される前にキーは登録済みです — core から見えない語彙まで guard が効くのはこのためです。拒否メッセージは所有パッケージ名を示し、それ自体が予約されていない代替名を提案します。
61
+
62
+ `context` は呼び出し側のもので、これらのキーはデプロイ側のものです。既定のサーバーでは `scopes` / `userId` / `clientId` は**署名検証済みトークン**から読まれ、`permissions` / `roles` は builtin の Rule が判断に使う権限を、cedar の 4 キーはパース済みリクエストを運びます。信頼レベルがまったく異なり、リクエストボディがそれらと同じバケツに合流してはなりません。
63
+
64
+ ドキュメントで注意喚起するのではなく拒否する価値があるのはマージの挙動ゆえです。`AttributePipeline` はコレクター間で配列値の attribute を**union** します。したがって `scopes` へのマッピングは `PayloadScopeCollector` の出力を上書きして競合するのではなく、*追加*します。`context.groups = ["admin:write"]` を送った呼び出し側は、トークンが一度も持たなかった scope で認可され、判定・ログ・メトリクスのいずれにも issuer が付与した場合との区別は現れません。`AttributePipeline` の merge の doc コメントを参照してください。
65
+
66
+ スカラーキーなら安全というわけでもなく、理由は 2 つあります。双方が書けば値が食い違い、`AttributeConflictError` でそのリクエストは拒否されます — fail-closed ではありますが、起動時の拒否ではなく予告のない拒否です。そして所有側のコレクターが*条件付きでしか*書かないキーには、そもそも競合相手がいません。cedar の `RequestFactsCollector` は `"document"` のような id を持たないリソースでは `requestResourceId` を書かないため、`{ from = "rid", to = "requestResourceId" }` は誰とも競合せずに着地し、Cedar の resource エンティティが呼び出し側のリクエストボディから組み立てられてしまいます。
67
+
43
68
  ## Rules
44
69
 
45
70
  ### HasPermission
package/README.md CHANGED
@@ -40,6 +40,31 @@ Each mapping is `{ from: string; to?: string; type?: "string" | "number" | "bool
40
40
 
41
41
  The declaration is the trust boundary — this collector is the ready-made way to stay on the right side of the one described in [docs/extending.md](../../docs/extending.md#the-trust-boundary-requestcontext-is-the-callers). `requestContext` is free-form and unvalidated, so **nothing undeclared becomes an attribute** and a configured dot path traverses own properties only (`constructor.name` reads nothing). This collector invents no vocabulary of its own: the operator names both the fields and the keys, which is what keeps [AGENTS.md — Core Vocabulary Scope](../../AGENTS.md#core-vocabulary-scope) intact while still shipping something usable. For anything beyond read-check-write — deriving a value, calling out to a store — write a focused project-side `AttributeCollector` as that section describes.
42
42
 
43
+ #### Reserved vocabulary is not a valid destination
44
+
45
+ A mapping's `to` may not name a **reserved attribute key**. Naming one is a **configuration error, refused at construction**, so a deployment that writes it fails at boot rather than on the first request:
46
+
47
+ ```hocon
48
+ # refused at boot — core's own vocabulary
49
+ { from = "groups", to = "scopes" }
50
+
51
+ # refused at boot — @o3co/auth.policy-verifier.cedar's vocabulary,
52
+ # reserved as soon as that package is loaded
53
+ { from = "rid", to = "requestResourceId" }
54
+
55
+ # fine — the field may be called anything; only the attribute key is reserved
56
+ { from = "groups", to = "requestGroups", type = "string[]" }
57
+ { from = "scopes", to = "requestedScopes", type = "string[]" }
58
+ ```
59
+
60
+ The reserved set is **not a list this package keeps**. It is core's registry (`RESERVED_ATTRIBUTE_KEYS`, `reserveAttributeKeys`, `attributeKeyReservation`): core reserves its own five — `scopes`, `permissions`, `roles`, `userId`, `clientId` — and every package that owns attribute vocabulary reserves its own at module load. `@o3co/auth.policy-verifier.cedar` reserves `requestAction`, `requestResourceType`, `requestResourceId` and `requestResourceRaw`; a project-side collector should reserve the keys it writes the same way. A composition that can name a package's collectors in config has already imported that package, so its keys are registered before this collector is constructed — which is why the guard covers vocabulary core cannot see. The refusal names the owning package, and suggests a rename that is not itself reserved.
61
+
62
+ `context` is the caller's, and those keys are the deployment's. Under the default server `scopes`, `userId` and `clientId` are read out of the **signature-verified token**, `permissions` / `roles` carry the entitlements the builtin rules decide from, and cedar's four carry the parsed request — so the two sides of such a mapping carry entirely different trust, and the request body must not join them in one bucket.
63
+
64
+ What makes it worth refusing rather than documenting is the merge: `AttributePipeline` **unions** array-valued attributes across collectors. A mapping onto `scopes` therefore does not overwrite what `PayloadScopeCollector` produced and lose an argument with it — it *extends* it. A caller sending `context.groups = ["admin:write"]` would be authorized for a scope its token never carried, and nothing in the decision, the logs or the metrics would tell that apart from an issuer that granted it. See `AttributePipeline`'s merge doc comment.
65
+
66
+ A scalar key is no safer, in two ways. Where both sides write it the values disagree and `AttributeConflictError` denies the request — fail-closed, but an unannounced denial rather than a refusal at boot. And where the owning collector writes its key only *sometimes* there is no second writer at all: cedar's `RequestFactsCollector` omits `requestResourceId` for an id-less resource such as `"document"`, so `{ from = "rid", to = "requestResourceId" }` would land unopposed and the Cedar resource entity would be built from the caller's own request body.
67
+
43
68
  ## Rules
44
69
 
45
70
  ### HasPermission
@@ -5,7 +5,13 @@ export type RequestContextAttributeType = "string" | "number" | "boolean" | "str
5
5
  export interface RequestContextAttributeMapping {
6
6
  /** Field to read, as a dot path (`"tenant.id"`) into `requestContext`. */
7
7
  from: string;
8
- /** Attribute key to write. Defaults to `from`. */
8
+ /**
9
+ * Attribute key to write. Defaults to `from`.
10
+ *
11
+ * May not name a reserved key — core's five, plus whatever the packages a
12
+ * composition loaded reserved for themselves. See the class doc comment for
13
+ * why the caller's body may not land on vocabulary a deployment writes.
14
+ */
9
15
  to?: string;
10
16
  /** Expected type; a value of any other shape is not promoted. Defaults to `"string"`. */
11
17
  type?: RequestContextAttributeType;
@@ -31,6 +37,48 @@ export interface RequestContextAttributeCollectorConfig {
31
37
  * config did not name cannot reach a rule, and one whose value does not match
32
38
  * its declared type is dropped rather than passed along.
33
39
  *
40
+ * ## Reserved vocabulary is not a valid destination
41
+ *
42
+ * A mapping's `to` may not name a key any package has reserved. Core reserves
43
+ * five — `scopes`, `permissions`, `roles`, `userId`, `clientId` — and every
44
+ * package that owns attribute vocabulary reserves its own at module load:
45
+ * `packages/cedar` reserves `requestAction`, `requestResourceType`,
46
+ * `requestResourceId` and `requestResourceRaw`. The check is a lookup in core's
47
+ * registry, not a list this collector or core keeps by hand, so a package core
48
+ * cannot see is covered by importing it — which a composition naming that
49
+ * package's collectors has already done.
50
+ *
51
+ * Those keys are what the engine decides from, and under the default server
52
+ * `scopes` / `userId` / `clientId` are read out of the signature-verified
53
+ * token; `requestContext` is the request body, which anyone holding a valid
54
+ * token fills in as they like. The two carry different trust and must not share
55
+ * a bucket.
56
+ *
57
+ * The reason it is refused outright rather than left to the operator is what
58
+ * `AttributePipeline` does with two collectors writing one key: array-valued
59
+ * entries **union**. So `{ from = "groups", to = "scopes" }` does not replace
60
+ * the token's scopes and lose an argument with `PayloadScopeCollector` — it
61
+ * quietly *adds to* them, and the request that arrives with
62
+ * `context.groups = ["admin:write"]` is authorized for a scope its token never
63
+ * carried. Nothing in the decision, the logs or the metrics distinguishes that
64
+ * from an issuer that granted it.
65
+ *
66
+ * A scalar key is not safe either, in two different ways. Two writers that
67
+ * disagree throw `AttributeConflictError`, which denies — fail-closed, but an
68
+ * unannounced outage rather than a refusal. And where the owning collector
69
+ * writes its key only *sometimes*, there is no second writer at all: cedar's
70
+ * `RequestFactsCollector` omits `requestResourceId` for an id-less resource
71
+ * such as `"document"`, so `{ from = "rid", to = "requestResourceId" }` would
72
+ * land unopposed and the Cedar resource entity would be built out of the
73
+ * caller's own request body.
74
+ *
75
+ * This is reachable only through operator configuration, which is why it is a
76
+ * configuration error rather than a vulnerability. It is refused at
77
+ * construction — boot, not first request — so a deployment that wrote it never
78
+ * serves a decision. Every other destination keeps working: the *field* may be
79
+ * called anything (`{ from = "scopes", to = "requestedScopes" }` is fine), only
80
+ * the attribute key is reserved.
81
+ *
34
82
  * ```hocon
35
83
  * { collector = "RequestContextAttributeCollector"
36
84
  * attributes = [
@@ -1 +1 @@
1
- {"version":3,"file":"RequestContextAttributeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/RequestContextAttributeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAClB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAGzC,yDAAyD;AACzD,MAAM,MAAM,2BAA2B,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AASvF,iEAAiE;AACjE,MAAM,WAAW,8BAA8B;IAC9C,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yFAAyF;IACzF,IAAI,CAAC,EAAE,2BAA2B,CAAC;CACnC;AAED,mEAAmE;AACnE,MAAM,WAAW,sCAAsC;IACtD,wDAAwD;IACxD,UAAU,EAAE,8BAA8B,EAAE,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,gCAAiC,YAAW,kBAAkB;IAC1E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;gBAE1D,MAAM,EAAE,sCAAsC;IA4BpD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAgB7D"}
1
+ {"version":3,"file":"RequestContextAttributeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/RequestContextAttributeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAElB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAQzC,yDAAyD;AACzD,MAAM,MAAM,2BAA2B,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AASvF,iEAAiE;AACjE,MAAM,WAAW,8BAA8B;IAC9C,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yFAAyF;IACzF,IAAI,CAAC,EAAE,2BAA2B,CAAC;CACnC;AAED,mEAAmE;AACnE,MAAM,WAAW,sCAAsC;IACtD,wDAAwD;IACxD,UAAU,EAAE,8BAA8B,EAAE,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,qBAAa,gCAAiC,YAAW,kBAAkB;IAC1E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;gBAE1D,MAAM,EAAE,sCAAsC;IAoCpD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAgB7D"}
@@ -1,6 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { readUntrustedRequestContext } from "@o3co/auth.policy-verifier.core";
3
+ import { attributeKeyReservation, CORE_ATTRIBUTE_KEY_OWNER, readUntrustedRequestContext, suggestUnreservedAttributeKey, } from "@o3co/auth.policy-verifier.core";
4
4
  const ATTRIBUTE_TYPES = [
5
5
  "string",
6
6
  "number",
@@ -23,6 +23,48 @@ const ATTRIBUTE_TYPES = [
23
23
  * config did not name cannot reach a rule, and one whose value does not match
24
24
  * its declared type is dropped rather than passed along.
25
25
  *
26
+ * ## Reserved vocabulary is not a valid destination
27
+ *
28
+ * A mapping's `to` may not name a key any package has reserved. Core reserves
29
+ * five — `scopes`, `permissions`, `roles`, `userId`, `clientId` — and every
30
+ * package that owns attribute vocabulary reserves its own at module load:
31
+ * `packages/cedar` reserves `requestAction`, `requestResourceType`,
32
+ * `requestResourceId` and `requestResourceRaw`. The check is a lookup in core's
33
+ * registry, not a list this collector or core keeps by hand, so a package core
34
+ * cannot see is covered by importing it — which a composition naming that
35
+ * package's collectors has already done.
36
+ *
37
+ * Those keys are what the engine decides from, and under the default server
38
+ * `scopes` / `userId` / `clientId` are read out of the signature-verified
39
+ * token; `requestContext` is the request body, which anyone holding a valid
40
+ * token fills in as they like. The two carry different trust and must not share
41
+ * a bucket.
42
+ *
43
+ * The reason it is refused outright rather than left to the operator is what
44
+ * `AttributePipeline` does with two collectors writing one key: array-valued
45
+ * entries **union**. So `{ from = "groups", to = "scopes" }` does not replace
46
+ * the token's scopes and lose an argument with `PayloadScopeCollector` — it
47
+ * quietly *adds to* them, and the request that arrives with
48
+ * `context.groups = ["admin:write"]` is authorized for a scope its token never
49
+ * carried. Nothing in the decision, the logs or the metrics distinguishes that
50
+ * from an issuer that granted it.
51
+ *
52
+ * A scalar key is not safe either, in two different ways. Two writers that
53
+ * disagree throw `AttributeConflictError`, which denies — fail-closed, but an
54
+ * unannounced outage rather than a refusal. And where the owning collector
55
+ * writes its key only *sometimes*, there is no second writer at all: cedar's
56
+ * `RequestFactsCollector` omits `requestResourceId` for an id-less resource
57
+ * such as `"document"`, so `{ from = "rid", to = "requestResourceId" }` would
58
+ * land unopposed and the Cedar resource entity would be built out of the
59
+ * caller's own request body.
60
+ *
61
+ * This is reachable only through operator configuration, which is why it is a
62
+ * configuration error rather than a vulnerability. It is refused at
63
+ * construction — boot, not first request — so a deployment that wrote it never
64
+ * serves a decision. Every other destination keeps working: the *field* may be
65
+ * called anything (`{ from = "scopes", to = "requestedScopes" }` is fine), only
66
+ * the attribute key is reserved.
67
+ *
26
68
  * ```hocon
27
69
  * { collector = "RequestContextAttributeCollector"
28
70
  * attributes = [
@@ -49,7 +91,15 @@ export class RequestContextAttributeCollector {
49
91
  if (!ATTRIBUTE_TYPES.includes(type)) {
50
92
  throw new Error(`RequestContextAttributeCollector: attributes[${index}].type must be one of ${ATTRIBUTE_TYPES.join(", ")}, got "${type}"`);
51
93
  }
52
- return { from, to: to ?? from, type };
94
+ // Checked on the resolved key, not on `to`: `to` defaults to `from`,
95
+ // so `{ from = "scopes" }` reaches the reserved key without ever
96
+ // spelling it out. See the class doc comment for why this is refused.
97
+ const key = to ?? from;
98
+ const reservation = attributeKeyReservation(key);
99
+ if (reservation !== undefined) {
100
+ throw new Error(refusal(index, reservation));
101
+ }
102
+ return { from, to: key, type };
53
103
  });
54
104
  }
55
105
  async collect(context) {
@@ -69,6 +119,36 @@ export class RequestContextAttributeCollector {
69
119
  return attrs;
70
120
  }
71
121
  }
122
+ /**
123
+ * Why this mapping is refused, in the words of whoever owns the key.
124
+ *
125
+ * Core's five get the account of the trust boundary they sit on. Another
126
+ * package's keys get its name and its own stated reason instead: asserting they
127
+ * are "the core vocabulary" would be false, and the operator's next move is to
128
+ * go read that package's docs, so the message says which one.
129
+ *
130
+ * The suggested rename is drawn from core's registry rather than spelled
131
+ * `request<Key>` unconditionally, because `request*` is a real package's
132
+ * namespace (cedar's) and advice that lands there would be refused by this same
133
+ * guard.
134
+ */
135
+ function refusal(index, reservation) {
136
+ const { key, owner, reason } = reservation;
137
+ const head = owner === CORE_ATTRIBUTE_KEY_OWNER
138
+ ? `RequestContextAttributeCollector: attributes[${index}] maps onto the reserved core attribute "${key}". ` +
139
+ "That key is the engine's own vocabulary, written by the deployment — from the " +
140
+ "signature-verified token for scopes/userId/clientId, from configuration for roles/permissions — " +
141
+ "while requestContext is caller-supplied, and array attributes union across collectors, so this " +
142
+ "mapping would let the request body extend the deployment's value rather than contribute a " +
143
+ "separate attribute. "
144
+ : `RequestContextAttributeCollector: attributes[${index}] maps onto the reserved attribute "${key}", ` +
145
+ `which belongs to ${owner}${reason === undefined ? "" : ` — ${reason}`}. ` +
146
+ "That package writes the key from what the deployment established, while requestContext is " +
147
+ "caller-supplied: where both write it the values collide and every such request is denied, and " +
148
+ "where that package writes nothing for a given request the caller's value stands unopposed as " +
149
+ "the one the deployment was supposed to supply. ";
150
+ return `${head}Promote the field under a key of your own (for example "${suggestUnreservedAttributeKey(key)}").`;
151
+ }
72
152
  /**
73
153
  * Reads a dot path, traversing own properties only. `requestContext` is
74
154
  * caller-supplied, so inherited members (`constructor`, `toString`, …) must not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth.policy-verifier.builtins",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@o3co/auth.policy-verifier.core": "0.7.0"
34
+ "@o3co/auth.policy-verifier.core": "0.8.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.4.0",