@o3co/auth.policy-verifier.builtins 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md
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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.0",
|
|
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.
|
|
34
|
+
"@o3co/auth.policy-verifier.core": "0.8.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^26.4.0",
|