@o3co/auth.policy-verifier.builtins 0.1.1 → 0.2.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
@@ -16,12 +16,15 @@ npm install @o3co/auth.policy-verifier.builtins
16
16
  | --- | --- | --- | --- |
17
17
  | `PayloadScopeCollector` | `payload.scope`(スペース区切り文字列) | `ATTR_SCOPES: string[]` | なし |
18
18
  | `PayloadSubjectIdCollector` | `payload.sub`、`payload.azp` | `ATTR_USER_ID`、`ATTR_CLIENT_ID` | なし |
19
- | `RequestContextCollector` | `context.requestContext.ip` | `ATTR_CLIENT_IP` | なし |
20
19
  | `StaticPermissionCollector` | — | `ATTR_PERMISSIONS: string[]` | `{ permissions: string[] }` |
21
20
  | `StaticRoleCollector` | — | `ATTR_ROLES: Role[]` | `{ roles: Role[] }` |
22
21
 
23
22
  `StaticPermissionCollector` と `StaticRoleCollector` は、リクエストのコンテキストに関わらず、コンストラクタに渡した値を常に出力します。
24
23
 
24
+ ### `requestContext` 向けの組み込みコレクターは提供しない
25
+
26
+ 本エンジンは `CollectorContext.requestContext` をそのまま attribute に展開するコレクターを提供しません。`requestContext` の形は利用側プロジェクトの transport/interceptor が定めるため、その解釈はプロジェクト側の責務です。必要なフィールドごとに焦点を絞った `AttributeCollector` を実装し、その中で値の型・形状を検証し、プロジェクト固有の定数キーで格納してください。詳細と具体例は [AGENTS.md — Core Vocabulary Scope](../../AGENTS.md#core-vocabulary-scope) を参照してください。
27
+
25
28
  ## Rules
26
29
 
27
30
  ### HasPermission
@@ -49,6 +52,17 @@ new HasScope(scope: string)
49
52
  - `ATTR_SCOPES` 内のプレフィックスなし scope 文字列(例: `"resource"`)は、比較前に `"read:resource"` へ正規化されます。
50
53
  - 正規化後に大文字・小文字を区別しない完全一致で比較します。
51
54
 
55
+ ### AttrMatchRule
56
+
57
+ ```ts
58
+ new AttrMatchRule({ a: string, b: string, group?: string })
59
+ ```
60
+
61
+ - `code`: `"attr_mismatch"`。
62
+ - `attrs.get(a)` と `attrs.get(b)` がいずれも非空文字列かつ等しいときに `true` を返します。それ以外はすべて `false`(fail closed)。
63
+ - 純粋な述語です。`CollectorContext` を参照しません。比較対象の値はプロジェクト側の上流 `AttributeCollector` が attrs に格納し、プロジェクト側の `RuleCollector` でこの Rule を構築します。
64
+ - `ruleType` の既定値は `"attr_match:${a}:${b}"` です。評価器は `ruleType` 内で OR、`ruleType` 間で AND を取るので、この既定値により異なる2つの比較は AND(両方必要)として扱われます。2つの比較を OR 結合したい場合(例「DID または email で一致」)は、両方の Rule に同じ `group` を指定してください。その `group` 値が `ruleType` として使われます。
65
+
52
66
  ## Rule Collectors
53
67
 
54
68
  | 名前 | 導出する permission / scope | 返り値 |
@@ -88,7 +102,6 @@ import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
88
102
  | --- | --- | --- |
89
103
  | `attributeCollector` | `"PayloadScopeCollector"` | `() => new PayloadScopeCollector()` |
90
104
  | `attributeCollector` | `"PayloadSubjectIdCollector"` | `() => new PayloadSubjectIdCollector()` |
91
- | `attributeCollector` | `"RequestContextCollector"` | `() => new RequestContextCollector()` |
92
105
  | `attributeCollector` | `"StaticPermissionCollector"` | `(config) => new StaticPermissionCollector(config)` |
93
106
  | `attributeCollector` | `"StaticRoleCollector"` | `(config) => new StaticRoleCollector(config)` |
94
107
  | `ruleCollector` | `"ResourceActionScopeRuleCollector"` | `() => new ResourceActionScopeRuleCollector()` |
package/README.md CHANGED
@@ -16,12 +16,15 @@ All collectors implement `AttributeCollector`.
16
16
  | --- | --- | --- | --- |
17
17
  | `PayloadScopeCollector` | `payload.scope` (space-separated string) | `ATTR_SCOPES: string[]` | none |
18
18
  | `PayloadSubjectIdCollector` | `payload.sub`, `payload.azp` | `ATTR_USER_ID`, `ATTR_CLIENT_ID` | none |
19
- | `RequestContextCollector` | `context.requestContext.ip` | `ATTR_CLIENT_IP` | none |
20
19
  | `StaticPermissionCollector` | — | `ATTR_PERMISSIONS: string[]` | `{ permissions: string[] }` |
21
20
  | `StaticRoleCollector` | — | `ATTR_ROLES: Role[]` | `{ roles: Role[] }` |
22
21
 
23
22
  `StaticPermissionCollector` and `StaticRoleCollector` always emit the values supplied at construction time, regardless of request context.
24
23
 
24
+ ### No built-in collector for `requestContext`
25
+
26
+ The engine does not ship a collector that expands `CollectorContext.requestContext` into attributes. The shape of `requestContext` is defined by each consuming project's transport/interceptor, so interpreting it is a project concern. Write a focused `AttributeCollector` for each field you need to promote — validate its shape there, and store it under a project-specific constant key. See [AGENTS.md — Core Vocabulary Scope](../../AGENTS.md#core-vocabulary-scope) for the rationale and a worked example.
27
+
25
28
  ## Rules
26
29
 
27
30
  ### HasPermission
@@ -49,6 +52,17 @@ new HasScope(scope: string)
49
52
  - A bare scope string `"resource"` in `ATTR_SCOPES` is normalized to `"read:resource"` before comparison.
50
53
  - Matching is case-insensitive exact match after normalization.
51
54
 
55
+ ### AttrMatchRule
56
+
57
+ ```ts
58
+ new AttrMatchRule({ a: string, b: string, group?: string })
59
+ ```
60
+
61
+ - `code`: `"attr_mismatch"`.
62
+ - Passes when `attrs.get(a)` and `attrs.get(b)` are both non-empty strings and equal. Any other case returns `false` (fail closed).
63
+ - Pure predicate — does not read `CollectorContext`. Consuming projects provide the two values to compare through upstream `AttributeCollector`s and wire the rule through their own `RuleCollector`.
64
+ - `ruleType` defaults to `"attr_match:${a}:${b}"`. The evaluator ORs rules within a `ruleType` and ANDs across different `ruleType`s, so the default ensures two independent comparisons are AND-combined (required together). Pass `group` explicitly when you want two comparisons to be OR-combined (for example, "identify by DID or by email") — both rules then share the provided `group` as their `ruleType`.
65
+
52
66
  ## Rule Collectors
53
67
 
54
68
  | Name | Derives permission/scope | Returns |
@@ -88,7 +102,6 @@ import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
88
102
  | --- | --- | --- |
89
103
  | `attributeCollector` | `"PayloadScopeCollector"` | `() => new PayloadScopeCollector()` |
90
104
  | `attributeCollector` | `"PayloadSubjectIdCollector"` | `() => new PayloadSubjectIdCollector()` |
91
- | `attributeCollector` | `"RequestContextCollector"` | `() => new RequestContextCollector()` |
92
105
  | `attributeCollector` | `"StaticPermissionCollector"` | `(config) => new StaticPermissionCollector(config)` |
93
106
  | `attributeCollector` | `"StaticRoleCollector"` | `(config) => new StaticRoleCollector(config)` |
94
107
  | `ruleCollector` | `"ResourceActionScopeRuleCollector"` | `() => new ResourceActionScopeRuleCollector()` |
@@ -18,7 +18,6 @@ describe("builtinCollectorsModule", () => {
18
18
  });
19
19
  expect(attributeCollectorRegistry.has("PayloadScopeCollector")).toBe(true);
20
20
  expect(attributeCollectorRegistry.has("PayloadSubjectIdCollector")).toBe(true);
21
- expect(attributeCollectorRegistry.has("RequestContextCollector")).toBe(true);
22
21
  expect(attributeCollectorRegistry.has("StaticPermissionCollector")).toBe(true);
23
22
  expect(attributeCollectorRegistry.has("StaticRoleCollector")).toBe(true);
24
23
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=AttrMatchRule.test.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AttrMatchRule.test.d.mts","sourceRoot":"","sources":["../../../src/__tests__/rules/AttrMatchRule.test.mts"],"names":[],"mappings":""}
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AttrMatchRule } from "#/rules/AttrMatchRule.mjs";
3
+ describe("AttrMatchRule", () => {
4
+ const rule = new AttrMatchRule({ a: "x", b: "y" });
5
+ it("returns true when both attrs are non-empty strings and equal", () => {
6
+ const attrs = new Map([
7
+ ["x", "same"],
8
+ ["y", "same"],
9
+ ]);
10
+ expect(rule.verify(attrs)).toBe(true);
11
+ });
12
+ it("returns false when the two attrs differ", () => {
13
+ const attrs = new Map([
14
+ ["x", "foo"],
15
+ ["y", "bar"],
16
+ ]);
17
+ expect(rule.verify(attrs)).toBe(false);
18
+ });
19
+ it("returns false when the first attr is missing", () => {
20
+ const attrs = new Map([["y", "same"]]);
21
+ expect(rule.verify(attrs)).toBe(false);
22
+ });
23
+ it("returns false when the second attr is missing", () => {
24
+ const attrs = new Map([["x", "same"]]);
25
+ expect(rule.verify(attrs)).toBe(false);
26
+ });
27
+ it("returns false when either attr is a non-string value", () => {
28
+ const attrs = new Map([
29
+ ["x", 42],
30
+ ["y", 42],
31
+ ]);
32
+ expect(rule.verify(attrs)).toBe(false);
33
+ });
34
+ it("returns false when either attr is an empty string", () => {
35
+ const attrs = new Map([
36
+ ["x", ""],
37
+ ["y", ""],
38
+ ]);
39
+ expect(rule.verify(attrs)).toBe(false);
40
+ });
41
+ it("derives ruleType from the pair of attribute keys by default", () => {
42
+ // Distinct pairs must produce distinct ruleType values. Otherwise
43
+ // evaluate() would OR them together within a single group, which
44
+ // weakens "require both comparisons to pass" intent.
45
+ const r1 = new AttrMatchRule({ a: "userId", b: "sub" });
46
+ const r2 = new AttrMatchRule({ a: "orgId", b: "org" });
47
+ expect(r1.ruleType).not.toBe(r2.ruleType);
48
+ expect(r1.ruleType).toContain("userId");
49
+ expect(r1.ruleType).toContain("sub");
50
+ });
51
+ it("uses the provided group override when set, to enable explicit grouping", () => {
52
+ // When callers want two comparisons to be OR'd together (e.g. match by
53
+ // either DID or email), they opt in by passing the same `group`.
54
+ const r1 = new AttrMatchRule({ a: "x", b: "y", group: "identity" });
55
+ const r2 = new AttrMatchRule({ a: "e", b: "f", group: "identity" });
56
+ expect(r1.ruleType).toBe("identity");
57
+ expect(r2.ruleType).toBe("identity");
58
+ });
59
+ it("has code 'attr_mismatch'", () => {
60
+ expect(rule.code).toBe("attr_mismatch");
61
+ });
62
+ it("message describes the contract (both attrs must be non-empty strings and equal)", () => {
63
+ // The message should make the rule's contract explicit, not just say "do not match",
64
+ // because verify() fails closed on missing, non-string, and empty inputs too.
65
+ expect(rule.message).toContain("x");
66
+ expect(rule.message).toContain("y");
67
+ expect(rule.message).toMatch(/non-empty/i);
68
+ expect(rule.message).toMatch(/equal/i);
69
+ expect(rule.message[0]).toBe(rule.message[0]?.toUpperCase());
70
+ });
71
+ });
@@ -1,14 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { ResourceActionScopeRuleCollector } from "#/rules/ResourceActionScopeRuleCollector.mjs";
3
- const makeContext = (resourceType, action) => ({
4
- payload: {},
3
+ const makeContext = (resourceType, action, scope) => ({
4
+ payload: { ...(scope !== undefined ? { scope } : {}) },
5
5
  resource: { raw: `${resourceType}:1`, resourceType, resourceId: "1" },
6
6
  action,
7
7
  });
8
8
  describe("ResourceActionScopeRuleCollector", () => {
9
9
  const collector = new ResourceActionScopeRuleCollector();
10
10
  it("creates a HasScope rule with action:resourceType", async () => {
11
- const rules = await collector.collect(makeContext("document", "read"));
11
+ const rules = await collector.collect(makeContext("document", "read", "read:document"));
12
12
  expect(rules).toHaveLength(1);
13
13
  expect(rules[0].ruleType).toBe("scope");
14
14
  const attrs = new Map([["scopes", ["read:document"]]]);
@@ -16,12 +16,33 @@ describe("ResourceActionScopeRuleCollector", () => {
16
16
  });
17
17
  it("creates correct scope for nested resource types", async () => {
18
18
  const ctx = {
19
- payload: {},
19
+ payload: { scope: "update:project_member" },
20
20
  resource: { raw: "project:1.member:2", resourceType: "project_member", resourceId: "2" },
21
21
  action: "update",
22
22
  };
23
23
  const rules = await collector.collect(ctx);
24
+ expect(rules).toHaveLength(1);
24
25
  const attrs = new Map([["scopes", ["update:project_member"]]]);
25
26
  expect(rules[0].verify(attrs)).toBe(true);
26
27
  });
28
+ it("returns no rules when payload has no scope claim", async () => {
29
+ const ctx = makeContext("document", "read");
30
+ // payload has no scope — simulates DID JWT
31
+ const rules = await collector.collect(ctx);
32
+ expect(rules).toHaveLength(0);
33
+ });
34
+ it("returns HasScope rule when payload has scope claim", async () => {
35
+ const ctx = {
36
+ payload: { scope: "read:document" },
37
+ resource: { raw: "document:1", resourceType: "document", resourceId: "1" },
38
+ action: "read",
39
+ };
40
+ const rules = await collector.collect(ctx);
41
+ expect(rules).toHaveLength(1);
42
+ expect(rules[0].ruleType).toBe("scope");
43
+ });
44
+ it("still generates HasScope rule when scope is empty string", async () => {
45
+ const rules = await collector.collect(makeContext("document", "read", ""));
46
+ expect(rules).toHaveLength(1);
47
+ });
27
48
  });
package/dist/index.d.mts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { PayloadScopeCollector } from "./collectors/PayloadScopeCollector.mjs";
2
2
  export { PayloadSubjectIdCollector } from "./collectors/PayloadSubjectIdCollector.mjs";
3
- export { RequestContextCollector } from "./collectors/RequestContextCollector.mjs";
4
3
  export { StaticPermissionCollector } from "./collectors/StaticPermissionCollector.mjs";
5
4
  export { StaticRoleCollector } from "./collectors/StaticRoleCollector.mjs";
6
5
  export { builtinCollectorsModule } from "./module.mjs";
7
6
  export { DotNotationResourceParser } from "./resource/DotNotationResourceParser.mjs";
7
+ export { AttrMatchRule, type AttrMatchRuleConfig } from "./rules/AttrMatchRule.mjs";
8
8
  export { HasPermission } from "./rules/HasPermission.mjs";
9
9
  export { HasScope } from "./rules/HasScope.mjs";
10
10
  export { ResourceActionPermissionRuleCollector } from "./rules/ResourceActionPermissionRuleCollector.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAC/E,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EAAE,uBAAuB,EAAE,MAAM,0CAA0C,CAAC;AACnF,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAE3E,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EAAE,yBAAyB,EAAE,MAAM,0CAA0C,CAAC;AAErF,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,qCAAqC,EAAE,MAAM,mDAAmD,CAAC;AAC1G,OAAO,EAAE,gCAAgC,EAAE,MAAM,8CAA8C,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAC/E,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAE3E,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EAAE,yBAAyB,EAAE,MAAM,0CAA0C,CAAC;AAErF,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,qCAAqC,EAAE,MAAM,mDAAmD,CAAC;AAC1G,OAAO,EAAE,gCAAgC,EAAE,MAAM,8CAA8C,CAAC"}
package/dist/index.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  // Collectors
2
2
  export { PayloadScopeCollector } from "./collectors/PayloadScopeCollector.mjs";
3
3
  export { PayloadSubjectIdCollector } from "./collectors/PayloadSubjectIdCollector.mjs";
4
- export { RequestContextCollector } from "./collectors/RequestContextCollector.mjs";
5
4
  export { StaticPermissionCollector } from "./collectors/StaticPermissionCollector.mjs";
6
5
  export { StaticRoleCollector } from "./collectors/StaticRoleCollector.mjs";
7
6
  // Module
@@ -9,6 +8,7 @@ export { builtinCollectorsModule } from "./module.mjs";
9
8
  // Resource
10
9
  export { DotNotationResourceParser } from "./resource/DotNotationResourceParser.mjs";
11
10
  // Rules
11
+ export { AttrMatchRule } from "./rules/AttrMatchRule.mjs";
12
12
  export { HasPermission } from "./rules/HasPermission.mjs";
13
13
  export { HasScope } from "./rules/HasScope.mjs";
14
14
  export { ResourceActionPermissionRuleCollector } from "./rules/ResourceActionPermissionRuleCollector.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"module.d.mts","sourceRoot":"","sources":["../src/module.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAU9D,eAAO,MAAM,uBAAuB,EAAE,MAyCrC,CAAC"}
1
+ {"version":3,"file":"module.d.mts","sourceRoot":"","sources":["../src/module.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAS9D,eAAO,MAAM,uBAAuB,EAAE,MAqCrC,CAAC"}
package/dist/module.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  import { PayloadScopeCollector } from "./collectors/PayloadScopeCollector.mjs";
2
2
  import { PayloadSubjectIdCollector } from "./collectors/PayloadSubjectIdCollector.mjs";
3
- import { RequestContextCollector } from "./collectors/RequestContextCollector.mjs";
4
3
  import { StaticPermissionCollector } from "./collectors/StaticPermissionCollector.mjs";
5
4
  import { StaticRoleCollector } from "./collectors/StaticRoleCollector.mjs";
6
5
  import { DotNotationResourceParser } from "./resource/DotNotationResourceParser.mjs";
@@ -12,7 +11,6 @@ export const builtinCollectorsModule = {
12
11
  // Attribute collector factories
13
12
  context.attributeCollectorRegistry.register("PayloadScopeCollector", () => new PayloadScopeCollector());
14
13
  context.attributeCollectorRegistry.register("PayloadSubjectIdCollector", () => new PayloadSubjectIdCollector());
15
- context.attributeCollectorRegistry.register("RequestContextCollector", () => new RequestContextCollector());
16
14
  context.attributeCollectorRegistry.register("StaticPermissionCollector", (config) => new StaticPermissionCollector(config));
17
15
  context.attributeCollectorRegistry.register("StaticRoleCollector", (config) => new StaticRoleCollector(config));
18
16
  // Rule collector factories
@@ -0,0 +1,42 @@
1
+ import type { Attributes, Rule } from "@o3co/auth.policy-verifier.core";
2
+ export interface AttrMatchRuleConfig {
3
+ a: string;
4
+ b: string;
5
+ /**
6
+ * Optional grouping key used as `ruleType`. See the class comment for
7
+ * how this interacts with the evaluator's OR-within-group /
8
+ * AND-across-groups semantics.
9
+ */
10
+ group?: string;
11
+ }
12
+ /**
13
+ * Rule that passes when two named attributes are present, are non-empty
14
+ * strings, and are equal. A pure predicate over the attributes map —
15
+ * `verify(attrs)` does not touch `CollectorContext` and does not close
16
+ * over request state.
17
+ *
18
+ * ## Grouping and the default ruleType
19
+ *
20
+ * The evaluator groups rules by `ruleType`, ORs within a group, and ANDs
21
+ * across groups. If every `AttrMatchRule` shared one fixed `ruleType`,
22
+ * two instances collected for different comparisons (e.g. subject vs.
23
+ * subscriber AND tenant vs. request-tenant) would be OR'd together and
24
+ * authorization would weaken silently.
25
+ *
26
+ * To default to the safe reading of "each distinct pair of attributes
27
+ * is a separate AND-combined requirement", `ruleType` is derived from
28
+ * `a` and `b` when `group` is not given: `attr_match:{a}:{b}`.
29
+ *
30
+ * When callers actually want two comparisons to be OR-combined
31
+ * (alternative identities, for example), they opt in explicitly by
32
+ * passing the same `group` to both rule instances.
33
+ */
34
+ export declare class AttrMatchRule implements Rule {
35
+ private readonly config;
36
+ readonly ruleType: string;
37
+ readonly code = "attr_mismatch";
38
+ readonly message: string;
39
+ constructor(config: AttrMatchRuleConfig);
40
+ verify(attrs: Attributes): boolean;
41
+ }
42
+ //# sourceMappingURL=AttrMatchRule.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AttrMatchRule.d.mts","sourceRoot":"","sources":["../../src/rules/AttrMatchRule.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,iCAAiC,CAAC;AAExE,MAAM,WAAW,mBAAmB;IACnC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,aAAc,YAAW,IAAI;IAK7B,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,mBAAmB;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEI,MAAM,EAAE,mBAAmB;IAKxD,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO;CAOlC"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Rule that passes when two named attributes are present, are non-empty
3
+ * strings, and are equal. A pure predicate over the attributes map —
4
+ * `verify(attrs)` does not touch `CollectorContext` and does not close
5
+ * over request state.
6
+ *
7
+ * ## Grouping and the default ruleType
8
+ *
9
+ * The evaluator groups rules by `ruleType`, ORs within a group, and ANDs
10
+ * across groups. If every `AttrMatchRule` shared one fixed `ruleType`,
11
+ * two instances collected for different comparisons (e.g. subject vs.
12
+ * subscriber AND tenant vs. request-tenant) would be OR'd together and
13
+ * authorization would weaken silently.
14
+ *
15
+ * To default to the safe reading of "each distinct pair of attributes
16
+ * is a separate AND-combined requirement", `ruleType` is derived from
17
+ * `a` and `b` when `group` is not given: `attr_match:{a}:{b}`.
18
+ *
19
+ * When callers actually want two comparisons to be OR-combined
20
+ * (alternative identities, for example), they opt in explicitly by
21
+ * passing the same `group` to both rule instances.
22
+ */
23
+ export class AttrMatchRule {
24
+ config;
25
+ ruleType;
26
+ code = "attr_mismatch";
27
+ message;
28
+ constructor(config) {
29
+ this.config = config;
30
+ this.ruleType = config.group ?? `attr_match:${config.a}:${config.b}`;
31
+ this.message = `Attribute constraint not satisfied: ${config.a} and ${config.b} must both be non-empty strings and equal.`;
32
+ }
33
+ verify(attrs) {
34
+ const a = attrs.get(this.config.a);
35
+ const b = attrs.get(this.config.b);
36
+ if (typeof a !== "string" || a.length === 0)
37
+ return false;
38
+ if (typeof b !== "string" || b.length === 0)
39
+ return false;
40
+ return a === b;
41
+ }
42
+ }
@@ -1,4 +1,13 @@
1
1
  import type { CollectorContext, Rule, RuleCollector } from "@o3co/auth.policy-verifier.core";
2
+ /**
3
+ * Generates a HasScope rule derived from the request action and resource type.
4
+ *
5
+ * When the JWT payload has no `scope` claim (e.g. DID-grant tokens), no rules
6
+ * are generated. The scope rule group is therefore absent from AND-evaluation,
7
+ * allowing other rule groups to decide independently.
8
+ *
9
+ * OAuth JWTs that carry a `scope` claim are validated as before.
10
+ */
2
11
  export declare class ResourceActionScopeRuleCollector implements RuleCollector {
3
12
  collect(context: CollectorContext): Promise<Rule[]>;
4
13
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ResourceActionScopeRuleCollector.d.mts","sourceRoot":"","sources":["../../src/rules/ResourceActionScopeRuleCollector.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAG7F,qBAAa,gCAAiC,YAAW,aAAa;IAC/D,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;CAIzD"}
1
+ {"version":3,"file":"ResourceActionScopeRuleCollector.d.mts","sourceRoot":"","sources":["../../src/rules/ResourceActionScopeRuleCollector.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAG7F;;;;;;;;GAQG;AACH,qBAAa,gCAAiC,YAAW,aAAa;IAC/D,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;CAOzD"}
@@ -1,6 +1,18 @@
1
1
  import { HasScope } from "./HasScope.mjs";
2
+ /**
3
+ * Generates a HasScope rule derived from the request action and resource type.
4
+ *
5
+ * When the JWT payload has no `scope` claim (e.g. DID-grant tokens), no rules
6
+ * are generated. The scope rule group is therefore absent from AND-evaluation,
7
+ * allowing other rule groups to decide independently.
8
+ *
9
+ * OAuth JWTs that carry a `scope` claim are validated as before.
10
+ */
2
11
  export class ResourceActionScopeRuleCollector {
3
12
  async collect(context) {
13
+ if (context.payload.scope === undefined) {
14
+ return [];
15
+ }
4
16
  const scope = `${context.action}:${context.resource.resourceType}`;
5
17
  return [new HasScope(scope)];
6
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth.policy-verifier.builtins",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -28,7 +28,7 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "@o3co/auth.policy-verifier.core": "0.1.1"
31
+ "@o3co/auth.policy-verifier.core": "0.2.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^25.1.0",
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=RequestContextCollector.test.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"RequestContextCollector.test.d.mts","sourceRoot":"","sources":["../../../src/__tests__/collectors/RequestContextCollector.test.mts"],"names":[],"mappings":""}
@@ -1,42 +0,0 @@
1
- // Copyright 2026 1o1 Co. Ltd.
2
- // SPDX-License-Identifier: Apache-2.0
3
- import { ATTR_CLIENT_IP } from "@o3co/auth.policy-verifier.core";
4
- import { describe, expect, it } from "vitest";
5
- import { RequestContextCollector } from "#/collectors/RequestContextCollector.mjs";
6
- const makeContext = (requestContext) => ({
7
- payload: {},
8
- resource: { raw: "test:1", resourceType: "test", resourceId: "1" },
9
- action: "read",
10
- requestContext,
11
- });
12
- describe("RequestContextCollector", () => {
13
- const collector = new RequestContextCollector();
14
- it("extracts clientIp from requestContext.ip", async () => {
15
- const attrs = await collector.collect(makeContext({ ip: "203.0.113.1" }));
16
- expect(attrs.get(ATTR_CLIENT_IP)).toBe("203.0.113.1");
17
- });
18
- it("returns empty when requestContext is undefined", async () => {
19
- const attrs = await collector.collect(makeContext());
20
- expect(attrs.size).toBe(0);
21
- });
22
- it("returns empty when ip is not a string", async () => {
23
- const attrs = await collector.collect(makeContext({ ip: 12345 }));
24
- expect(attrs.size).toBe(0);
25
- });
26
- it("returns empty when ip is missing from requestContext", async () => {
27
- const attrs = await collector.collect(makeContext({ other: "value" }));
28
- expect(attrs.size).toBe(0);
29
- });
30
- it("returns empty when ip is empty string", async () => {
31
- const attrs = await collector.collect(makeContext({ ip: "" }));
32
- expect(attrs.size).toBe(0);
33
- });
34
- it("returns empty when ip is whitespace only", async () => {
35
- const attrs = await collector.collect(makeContext({ ip: " " }));
36
- expect(attrs.size).toBe(0);
37
- });
38
- it("trims whitespace from ip", async () => {
39
- const attrs = await collector.collect(makeContext({ ip: " 10.0.0.1 " }));
40
- expect(attrs.get(ATTR_CLIENT_IP)).toBe("10.0.0.1");
41
- });
42
- });
@@ -1,5 +0,0 @@
1
- import type { AttributeCollector, Attributes, CollectorContext } from "@o3co/auth.policy-verifier.core";
2
- export declare class RequestContextCollector implements AttributeCollector {
3
- collect(context: CollectorContext): Promise<Attributes>;
4
- }
5
- //# sourceMappingURL=RequestContextCollector.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"RequestContextCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/RequestContextCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAClB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAGzC,qBAAa,uBAAwB,YAAW,kBAAkB;IAC3D,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAS7D"}
@@ -1,15 +0,0 @@
1
- // Copyright 2026 1o1 Co. Ltd.
2
- // SPDX-License-Identifier: Apache-2.0
3
- import { ATTR_CLIENT_IP } from "@o3co/auth.policy-verifier.core";
4
- export class RequestContextCollector {
5
- async collect(context) {
6
- const attrs = new Map();
7
- const raw = context.requestContext?.ip;
8
- if (typeof raw === "string") {
9
- const ip = raw.trim();
10
- if (ip)
11
- attrs.set(ATTR_CLIENT_IP, ip);
12
- }
13
- return attrs;
14
- }
15
- }