action_policy-authzen 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b3423a534be53692df47668240b045ffed8324f03d71ddf36053d701096c6f70
4
+ data.tar.gz: afccffd23b80d3048fdbf3b8fbc37f7440e8976e947dd6a7c40c58d2abe8aee9
5
+ SHA512:
6
+ metadata.gz: 81022906cf5c83fbfc760fcd951ea658e462b4b706c9dee32a8b9136a21f39a2376d213d83e539ea6b681f31d8a798c2af90444ae909191476c9bc910e4909f7
7
+ data.tar.gz: e465c8a1dffce6f77041d83373e4235338f45017864e89cea361476d9e211b0d5fb1f806ed174a15eb6819c3e7f8c2ebcff7b9d23903a879300d0865475925b8
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 kajisha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # action_policy-authzen
2
+
3
+ Use OpenID AuthZEN authorization services from Action Policy rules and scopes.
4
+ This Ruby gem is a **PEP-side client** for Authorization API 1.0: single and batch
5
+ evaluation, subject/resource/action search, pagination, and PDP discovery.
6
+ It is not a PDP server or an OpenID Certified implementation.
7
+
8
+ See [conformance and limitations](docs/conformance.md) and
9
+ [official scenario coverage](docs/official-scenarios.md).
10
+
11
+ ## Installation
12
+
13
+ Requires Ruby 3.3 or later. This gem has not yet been published to RubyGems.
14
+ Until release, use a local checkout:
15
+
16
+ ```ruby
17
+ gem "action_policy-authzen", path: "/path/to/action_policy-authzen"
18
+ ```
19
+
20
+ For development:
21
+
22
+ ```sh
23
+ bundle install
24
+ bundle exec rake test
25
+ ```
26
+
27
+ ## Action Policy integration
28
+
29
+ ```ruby
30
+ require "action_policy/authzen"
31
+
32
+ class DocumentPolicy < ActionPolicy::Base
33
+ include ActionPolicy::AuthZEN::Policy
34
+
35
+ def show?
36
+ authzen_allowed?(
37
+ subject: {type: "user", id: user.id.to_s},
38
+ action: "reader",
39
+ resource: {type: "document", id: record.id.to_s}
40
+ )
41
+ end
42
+ end
43
+
44
+ client = ActionPolicy::AuthZEN::Client.new(
45
+ endpoint: ENV.fetch("AUTHZEN_EVALUATION_URL"),
46
+ headers: {"Authorization" => "Bearer #{ENV.fetch('AUTHZEN_TOKEN')}"},
47
+ open_timeout: 2,
48
+ read_timeout: 5
49
+ )
50
+
51
+ policy = DocumentPolicy.new(document, user: current_user, authzen_client: client)
52
+ policy.apply(:show?)
53
+ ```
54
+
55
+ In a Rails controller, pass the client through Action Policy's authorization context:
56
+
57
+ ```ruby
58
+ authorize! document, to: :show?, context: {authzen_client: client}
59
+ ```
60
+
61
+ Map entity types, IDs, and actions explicitly. There is no automatic mapping from
62
+ `show?` to `reader`. With OpenFGA, `action.name` must match a model relation.
63
+ Entity `properties` and request `context` are supported; construct them from
64
+ trusted application data.
65
+
66
+ ## Decisions and errors
67
+
68
+ `allowed?(subject:, action:, resource:, context: nil)` returns a boolean.
69
+ `evaluate(...)` returns a Hash with string keys, including optional decision
70
+ context. Applications must interpret obligations; `allowed?` does not execute them.
71
+
72
+ The client requires HTTP 200, JSON content, and boolean decisions. Failures raise:
73
+
74
+ | Exception | Meaning |
75
+ | --- | --- |
76
+ | `ActionPolicy::AuthZEN::HTTPError` | Non-200 response, including redirects; exposes `status` |
77
+ | `ActionPolicy::AuthZEN::InvalidResponse` | Invalid JSON, decision, context, search result, or metadata |
78
+ | `ActionPolicy::AuthZEN::TransportError` | Connection, TLS, or timeout failure |
79
+ | `ActionPolicy::AuthZEN::UnsupportedEndpoint` | Optional endpoint missing from discovered metadata |
80
+ | `ArgumentError` | Invalid request, URL, or timeout |
81
+
82
+ Exceptions propagate through policies. Never convert a transport failure into an
83
+ allow decision: deny access or report service unavailability. The client does not
84
+ follow redirects or retry requests. Error messages omit response bodies and
85
+ authentication headers.
86
+
87
+ HTTPS and certificate/hostname verification are enabled by default. Configure a
88
+ private CA with `ca_file: "/path/to/ca.pem"`. Use `allow_http: true` only for local
89
+ tests, and configure the authentication required by your production PDP.
90
+
91
+ The gem adds no cache. Action Policy's rule caching still applies; consider policy
92
+ instance lifetime and cache settings when permissions change.
93
+
94
+ ## Discovery and endpoints
95
+
96
+ `endpoint:` specifies a single evaluation URL. Use `base_url:` for all APIs or
97
+ discover the PDP's advertised endpoints:
98
+
99
+ ```ruby
100
+ client = ActionPolicy::AuthZEN::Client.new(base_url: "https://pdp.example.com/tenant")
101
+
102
+ client = ActionPolicy::AuthZEN::Client.discover(
103
+ base_url: "https://pdp.example.com/tenant",
104
+ headers: {"Authorization" => "Bearer #{ENV.fetch('AUTHZEN_TOKEN')}"}
105
+ )
106
+ client.metadata
107
+ ```
108
+
109
+ Discovery requests `/.well-known/authzen-configuration/tenant`, checks exact PDP
110
+ identity and endpoint URLs, and installs the advertised endpoints. Missing optional
111
+ endpoints raise `UnsupportedEndpoint`. Without discovery, `base_url:` uses standard
112
+ paths. Override individual URLs with
113
+ `endpoints: {evaluations: "https://pdp.example.com/custom/batch"}`.
114
+
115
+ Cross-origin discovery endpoints require an explicit
116
+ `trusted_origins: ["https://other-pdp.example.com"]` allowlist before credentials
117
+ can be forwarded. Optional `signed_metadata` is ignored; only plain JSON metadata
118
+ is used, without any claim of signature verification.
119
+
120
+ ## Batch evaluation
121
+
122
+ ```ruby
123
+ result = client.evaluations(
124
+ subject: {type: "user", id: "anne"},
125
+ resource: {type: "document", id: "roadmap"},
126
+ evaluations: [{action: "reader"}, {action: "writer"}],
127
+ options: {evaluations_semantic: "execute_all"}
128
+ )
129
+ decisions = result.fetch("evaluations").map { |entry| entry.fetch("decision") }
130
+ ```
131
+
132
+ Supported semantics are `execute_all`, `deny_on_first_deny`, and
133
+ `permit_on_first_permit`. Response cardinality, boolean types, and short-circuit
134
+ positions are validated. Individual context is preserved. Per-item values replace
135
+ top-level defaults as **whole objects**, not merged sub-fields.
136
+
137
+ Absent or empty `evaluations` requests a single evaluation using the defaults.
138
+ Depending on the PDP, the response contains a single `decision` or a one-element
139
+ `evaluations` array. Nonempty requests always require an `evaluations` response.
140
+
141
+ In policies, use `authzen_evaluations(...)`. Do not return its Hash as a boolean
142
+ rule result; explicitly aggregate individual decisions with `all?` or `any?` as
143
+ appropriate.
144
+
145
+ ## Search and pagination
146
+
147
+ ```ruby
148
+ client.search_subjects(subject: {type: "user"}, action: "reader",
149
+ resource: {type: "document", id: "roadmap"})
150
+ client.search_actions(subject: {type: "user", id: "anne"},
151
+ resource: {type: "document", id: "roadmap"})
152
+
153
+ client.each_resource(subject: {type: "user", id: "anne"}, action: "reader",
154
+ resource: {type: "document"}, page: {limit: 100}).each do |resource|
155
+ puts resource.fetch("id")
156
+ end
157
+ ```
158
+
159
+ `search_subjects`, `search_resources`, and `search_actions` return one response
160
+ Hash. `each_subject`, `each_resource`, and `each_action` accept a block or return
161
+ an Enumerator. They preserve query conditions and follow opaque tokens, rejecting
162
+ token cycles and malformed responses. Results are not deduplicated; reauthorize
163
+ before acting because permissions may change after a search.
164
+
165
+ Policy helpers use the `authzen_` prefix and are private. For example:
166
+
167
+ ```ruby
168
+ scope_for :array do |records|
169
+ ids = authzen_each_resource(subject: {type: "user", id: user.id.to_s},
170
+ action: "reader", resource: {type: "document"}).map { |entry| entry.fetch("id") }
171
+ records.select { |record| ids.include?(record.id.to_s) }
172
+ end
173
+ ```
174
+
175
+ Consider result size before collecting IDs in memory or constructing database scopes.
176
+
177
+ ## Integration tests
178
+
179
+ Use isolated test services, never production PDPs. OpenFGA 1.21.0 runs with
180
+ `--experimentals=authzen`, in-memory storage, and a loopback HTTP port:
181
+
182
+ ```sh
183
+ docker compose up -d
184
+ OPENFGA_URL=http://127.0.0.1:18080 bundle exec rake integration
185
+ docker compose down
186
+ ```
187
+
188
+ Tests create and remove their own store, model, and tuples through OpenFGA's native
189
+ API, then verify AuthZEN decisions and native Check parity. Client evaluation URLs
190
+ include the store, for example
191
+ `http://127.0.0.1:18080/stores/STORE_ID/access/v1/evaluation`. Pass the model ID in
192
+ `headers: {"Openfga-Authorization-Model-Id" => model_id}` and enable `allow_http`
193
+ for this local setup. Experimental APIs may change; test versions are pinned.
194
+
195
+ Linux x86_64 runners download pinned official binaries, start a local PDP, run its
196
+ tests, and stop the process. They require installed gems, `curl`, `unzip`, and
197
+ `sha256sum`:
198
+
199
+ ```sh
200
+ bash script/test-opa
201
+ bash script/test-topaz
202
+ bash script/test-opa official
203
+ ```
204
+
205
+ OPA AuthZEN plugin 0.8.0 is a separate OPA extension. It covers all APIs,
206
+ short-circuit evaluation, and paginated searches. Topaz 0.33.20 ignores
207
+ short-circuit options; tests check that the client rejects those responses.
208
+ Its unpaginated search is permitted by the official scenario.
209
+
210
+ OPA uses `OPA_PORT` (default `18181`). Topaz uses `TOPAZ_URL` (default
211
+ `http://127.0.0.1:18383`) and `TOPAZ_GRPC` (default `127.0.0.1:19292`). Logs and
212
+ test data remain under `/tmp/action-policy-authzen-*`. Tests skip only when their
213
+ PDP URL is unset; unavailable configured services fail the tests.
214
+
215
+ The `official` runner uses a separate fixture based on the pinned OpenID AuthZEN
216
+ WG scenario. [Case IDs, source provenance, and exclusions](docs/official-scenarios.md)
217
+ are recorded explicitly. These are project-owned tests, not a run of the official
218
+ conformance suite or a certification. CI includes all three PDPs and the scenario runner.
219
+
220
+ ## License
221
+
222
+ [MIT](LICENSE.txt), the same license as
223
+ [Action Policy](https://github.com/palkan/action_policy/blob/v0.7.7/LICENSE.txt).
224
+ Vendored Topaz test fixtures retain their Apache-2.0 license and attribution in
225
+ `test/fixtures/topaz/LICENSE` and `test/fixtures/topaz/PROVENANCE.md`.
@@ -0,0 +1,81 @@
1
+ # AuthZEN 1.0 scope and verification
2
+
3
+ The target is [Authorization API 1.0 Final (January 11, 2026)](https://openid.net/specs/authorization-api-1_0.html).
4
+ This library is a **PEP-side client** for Action Policy, not a PDP server or an
5
+ officially certified implementation.
6
+
7
+ ## API coverage
8
+
9
+ | Specification | Client API | Coverage |
10
+ | --- | --- | --- |
11
+ | Section 6: evaluation | `evaluate`, `allowed?` | Allow/deny, properties, context, malformed responses, transport failures |
12
+ | Section 7: batch evaluation | `evaluations` | Defaults, whole-object replacement, empty arrays, all three semantics, short-circuit position, individual errors |
13
+ | Section 8.4: subject search | `search_subjects`, `each_subject` | Type-only search target and result validation |
14
+ | Section 8.5: resource search | `search_resources`, `each_resource` | Type-only search target and Action Policy scopes |
15
+ | Section 8.6: action search | `search_actions`, `each_action` | Requests without action, result name validation |
16
+ | Section 8.2: pagination | `page:`, `each_*` | Opaque tokens, zero limit, empty pages, termination, cycles, immutable query |
17
+ | Section 9: discovery | `Client.discover`, `configuration`, `metadata` | Tenant paths, exact identity, advertised URLs, absent optional endpoints |
18
+ | Section 10: transport | All methods | JSON objects, HTTP 200, Content-Type, X-Request-ID, TLS certificate and hostname verification |
19
+
20
+ Policy helpers add the `authzen_` prefix to instance methods, except the
21
+ client-only `metadata` reader. Decisions must be booleans. Invalid or partial
22
+ responses are never interpreted as authorization. Unknown response fields are
23
+ preserved but do not influence decisions.
24
+
25
+ ## Optional features and boundaries
26
+
27
+ - **Signed metadata:** intentionally unsupported. Section 9.1.3 permits a PEP to ignore `signed_metadata`; the client validates plain JSON metadata only.
28
+ - **Capabilities:** exposed as metadata, not automatically implemented. Unsupported pagination extension keys are rejected; use standard `page.properties` for implementation-specific attributes.
29
+ - **Authentication:** outside the specification's scope. Supply bearer tokens or other headers through `headers:`; token issuance and renewal belong to the application.
30
+ - **HTTPS:** required by default. `allow_http: true` is an explicit local-test exception. `ca_file:` configures a trusted CA without disabling verification.
31
+ - **Discovery:** cross-origin endpoints require `trusted_origins:`. Missing advertised optional endpoints raise `UnsupportedEndpoint`; nondiscovery configurations use standard paths.
32
+ - **Search:** not an atomic permission snapshot. Deduplication and reauthorization before an operation belong to the application.
33
+ - **Decision context:** preserved, including reasons, errors, and obligations. Application-specific obligations are not executed automatically.
34
+
35
+ ## PDP targets
36
+
37
+ - [OpenFGA 1.21.0](https://github.com/openfga/openfga/releases/tag/v1.21.0): tested with `authzen` enabled. Native APIs provision stores/models/tuples; AuthZEN exercises evaluation, batch, all searches, and discovery. Its [documented lack of pagination](https://openfga.dev/docs/interacting/authzen) is not used as pagination evidence.
38
+ - [OPA AuthZEN plugin 0.8.0](https://github.com/kanywst/opa-authzen-plugin/tree/v0.8.0): a separate extension, not stock OPA. Tested across all six endpoints, all batch semantics, paginated searches, reauthorization, and Action Policy integration. The Linux x86_64 runner pins the binary SHA-256.
39
+ - [Topaz 0.33.20](https://github.com/aserto-dev/topaz/releases/tag/v0.33.20): all endpoints and `execute_all` work. It ignores short-circuit options; tests verify client rejection. Returning all search results despite `limit`, with an empty token, is permitted by scenario c-4-5-4 and is not itself nonconformance.
40
+ - Cerbos and Keycloak were investigated, but all three locally testable search endpoints were not established, so they were not selected for full-API testing.
41
+
42
+ ## Verification evidence
43
+
44
+ The [official scenario matrix](official-scenarios.md) separates applicable client
45
+ checks from PDP-only requirements and unsupported optional features.
46
+
47
+ Recorded baseline on September 22, 2026, using Ruby 3.2.1:
48
+
49
+ | Target | Tests | Assertions | Result |
50
+ | --- | ---: | ---: | --- |
51
+ | Unit HTTP/TLS, including scenario regressions | 65 | 237 | Passed |
52
+ | OpenFGA 1.21.0 | 4 | 23 | Passed |
53
+ | OPA AuthZEN plugin 0.8.0 | 3 | 35 | Passed |
54
+ | Official WG scenario / OPA plugin 0.8.0 | 35 | 152 | Passed using a project-owned runner |
55
+ | Topaz 0.33.20 | 7 | 25 | Passed, including known-incompatibility checks |
56
+
57
+ These PDP tests used official release binaries locally. Topaz Compose and GitHub
58
+ Actions were not executed in that environment because Docker was unavailable.
59
+ Ruby/shell syntax and gem build checks passed. The minimum supported Ruby version
60
+ is now 3.3, and CI is configured for Ruby 3.3, 3.4, and 4.0. The Ruby 3.2.1 results
61
+ are historical evidence, not verification on the currently supported versions.
62
+
63
+ `bundle exec rake test` uses local HTTP/TLS servers for valid and malformed
64
+ responses, short-circuit positions, pagination, metadata, and certificate failures.
65
+ `bundle exec rake integration` uses `OPENFGA_URL`, `TOPAZ_URL`, `OPA_URL`, and
66
+ `OFFICIAL_PDP_URL`. The official fixture needs a separate PDP process/port from
67
+ the basic OPA fixture. Only unset URLs skip tests; configured service failures fail.
68
+
69
+ Interop results are not official certification or a guarantee of compatibility
70
+ with every PDP.
71
+
72
+ After the documentation translation and client cleanup, Ruby 3.2.1 verification
73
+ passed 66 unit tests / 253 assertions, 35 official-scenario tests / 152 assertions,
74
+ and 3 OPA integration tests / 35 assertions, with no failures, errors, or skips.
75
+ Ruby/shell/YAML syntax and gem build checks also passed. OpenFGA, Topaz, and GitHub
76
+ Actions were not rerun for this cleanup; their results above are earlier evidence.
77
+
78
+ After raising the minimum Ruby version to 3.3, local verification on Ruby 3.3.5
79
+ passed 66 unit tests / 253 assertions with no failures, errors, or skips. Ruby
80
+ requirement checks, workflow YAML parsing, and gem build also passed. PDP
81
+ integration tests and the Ruby 3.4/4.0 CI jobs were not rerun for this change.
@@ -0,0 +1,103 @@
1
+ # Official AuthZEN scenario coverage
2
+
3
+ ## Pinned source
4
+
5
+ - [OpenID AuthZEN WG Certification Scenario](https://github.com/openid/authzen/blob/6ed00bad5daa8f6eef6f2aef1f124442beeb8382/certification/authorization-api-1_0-scenario.md)
6
+ - Repository: `openid/authzen`
7
+ - Commit: `6ed00bad5daa8f6eef6f2aef1f124442beeb8382`
8
+ - File: `certification/authorization-api-1_0-scenario.md`
9
+ - SHA-256: `dc6846304169f32077c2f4a74a0912fff2df734a20999762b0055e15ac8ce534`
10
+ - Retrieved: September 22, 2026
11
+
12
+ These are **project-owned PEP interoperability and regression tests based on the
13
+ official scenario**, not results from the OpenID Foundation Java conformance
14
+ suite. The scenario targets PDPs; PDP responsibilities are not counted as PEP passes.
15
+
16
+ The normative reference is [Authorization API 1.0 Final](https://openid.net/specs/authorization-api-1_0.html).
17
+ The scenario revision is pinned to prevent silent changes to expectations.
18
+ References use upstream `c-...` anchors rather than rendered section numbers.
19
+
20
+ ## Running the tests
21
+
22
+ ```sh
23
+ bundle exec rake test
24
+ bash script/test-opa official
25
+ ```
26
+
27
+ The second command downloads a checksum-pinned OPA AuthZEN plugin 0.8.0 binary for
28
+ Linux x86_64 and loads a dedicated Rego fixture implementing the eight decision
29
+ rules and S1-S6. It is separate from the basic OPA fixture. The runner stops its
30
+ process on exit and is included in the CI OPA job.
31
+
32
+ For an existing test PDP loaded with the same fixture:
33
+
34
+ ```sh
35
+ OFFICIAL_PDP_URL=http://127.0.0.1:18181 bundle exec ruby -Itest test/integration/official_scenario_test.rb
36
+ ```
37
+
38
+ Only an unset URL skips tests. This runner additionally checks OPA's multipage
39
+ support, so an external PDP must support pagination too, although the official
40
+ scenario allows it to be absent. Local HTTP integration and TLS unit tests are
41
+ separate evidence.
42
+
43
+ The September 22, 2026 baseline on Ruby 3.2.1 recorded **35 integration tests / 152
44
+ assertions**, with no failures, errors, or skips. All 31 JSON request fixtures were
45
+ checked for structural equality with the pinned source. Scenario-specific unit
46
+ checks passed **12 tests / 46 assertions**; the full unit baseline was **65 tests /
47
+ 237 assertions**. These counts are not official case or certification pass counts.
48
+
49
+ ## Coverage matrix
50
+
51
+ - **Live:** official requests and expected results exercised through the gem against the dedicated OPA fixture.
52
+ - **PEP:** corresponding client-side input/response checks, not execution of the official PDP test.
53
+ - **Out of scope:** PDP server behavior, excluded from this gem's pass count.
54
+ - **Partial:** tested subset with exclusions stated explicitly.
55
+
56
+ `I` means `test/integration/official_scenario_test.rb`; `U` means
57
+ `test/official_scenario_test.rb`. Their test names retain case IDs. Other references
58
+ name existing regression tests under `test/`.
59
+
60
+ | Official ID | Coverage | Evidence and boundary |
61
+ | --- | --- | --- |
62
+ | c-1-4 / c-1-5 | Live | I: eight decision rules and S1-S6; test-only policy |
63
+ | c-2-1 / c-3-1 / c-4-1 | Live / PEP | I; `search_test.rb#test_each_search_uses_its_own_schema_and_path`; `discovery_test.rb#test_discovered_client_uses_advertised_nonstandard_urls` |
64
+ | c-2-2-1 through c-2-2-8 | Live | I: allow, deny, context, and entity properties |
65
+ | c-2-2-9 | Out of scope | PDP ignores unknown top-level request fields. The typed client has no API to send them. U checks that unknown response fields do not affect decisions |
66
+ | c-2-3-1 / c-2-3-2 | PEP | U: decision presence/type and context shape/preservation; malformed JSON in `client_test.rb#test_rejects_non_boolean_or_missing_decisions` |
67
+ | c-2-4-1 / c-2-4-2 / c-2-4-6 | PEP | U: missing fields/sub-fields and invalid types rejected before HTTP. PDP generation of HTTP 400 is out of scope |
68
+ | c-2-4-3 / c-2-4-4 / c-2-4-5 | Out of scope | PDP handling of invalid Content-Type, malformed JSON, and empty bodies. The gem generates JSON objects. Received HTTP 400 is covered by `client_test.rb#test_http_errors_and_redirects_never_authorize` |
69
+ | c-2-5-1 | Partial | U: explicit X-Request-ID sent. Server echo is not checked; the client does not expose response headers |
70
+ | c-2-5-2 | Out of scope | PDP acceptance without a request ID. Client-generated IDs are tested in `client_test.rb#test_serializes_authzen_request_and_preserves_endpoint_and_headers` |
71
+ | c-2-6 | Live | I: repeated identical fixture requests |
72
+ | c-3-2-1 through c-3-2-7 | Live | I: independent items, defaults, properties, context inheritance, whole-object replacement |
73
+ | c-3-3-1 through c-3-3-4 | Live / PEP | I and U: decision order, count, and types; top-level decision does not override individual results |
74
+ | c-3-4-1 | Partial | U: preserve individual false/error responses. The official resource-less item is rejected by client input validation, not sent to a PDP |
75
+ | c-3-4-2 / c-3-4-3 | Live / PEP | I: absent/empty evaluations; `evaluations_test.rb#test_absent_and_empty_evaluations_support_single_response` accepts a single decision or one-element array |
76
+ | c-4-2-1 through c-4-2-4 | Live | I: subject search, context, ignored target ID, resource properties |
77
+ | c-4-3-1 through c-4-3-4 | Live | I: resource search, context, ignored target ID, subject properties |
78
+ | c-4-4-1 through c-4-4-3 | Live | I: action search, context, properties |
79
+ | c-4-5-1 / c-4-5-2 | Live | I: limit and opaque token, with additional multipage checks |
80
+ | c-4-5-3 | Live / PEP | I; `search_test.rb#test_invalid_search_responses_are_rejected`: page/token/count/total types and termination |
81
+ | c-4-5-4 | PEP | U: accept unpaginated results, absent page, empty token, and ignored limit |
82
+ | c-4-6-1 / c-4-6-2 | Live | I: empty results for unknown identifiers/types |
83
+ | c-4-7-1 / c-4-7-2 | PEP | U: required search fields/sub-fields rejected before HTTP. PDP generation of HTTP 400 is out of scope |
84
+ | c-5 | Partial | `tls_test.rb`: CA/hostname verification and explicit HTTP opt-in. U: JSON and ID. PDP unknown-field handling and header echo are out of scope |
85
+ | c-6-1 | PEP | `discovery_test.rb#test_configuration_inserts_well_known_before_tenant_path`: GET and tenant path |
86
+ | c-6-2 / c-6-3 | PEP | `discovery_test.rb#test_malformed_metadata_is_rejected` and `test_discovery_rejects_non_json_content_type`: required values, URLs, JSON types |
87
+ | c-6-4 / c-6-5 | Partial | `discovery_test.rb`: optional endpoints/capabilities, exact identity, unknown fields. Signed metadata is intentionally unsupported, not verified |
88
+ | c-6-6 | PEP | U: discovery 404 raises; an explicit nondiscovery configuration can use default paths |
89
+
90
+ ## Adaptations and limits
91
+
92
+ - **Pagination is optional (c-4-5-4).** Ignoring `limit` and returning all results with an empty token is permitted. This describes Topaz's search behavior, independently of its short-circuit incompatibility.
93
+ - **Stable pagination query:** the c-4-5-2 example omits the initial `limit`; our follow-up preserves all values except the token, following Final Section 8.2.1.
94
+ - **Search matching:** official expectations require inclusion, not an exact result set. Adding context or a search-target ID must also leave the baseline results unchanged.
95
+ - **Signed metadata:** the PDP scenario includes signature checks; this PEP intentionally does not implement the optional feature, as allowed by Final Section 9.1.3.
96
+ - **Suite boundary:** this matrix maps the pinned WG scenario, not every GitLab conformance-suite module.
97
+
98
+ ## Updating the source
99
+
100
+ Update the commit and SHA-256, compare added/removed `c-...` anchors, request JSON,
101
+ and expected results, then update the matrix and tests or record a coverage gap.
102
+ Run CI before claiming new coverage. Tests never fetch expectations from `main`
103
+ at runtime.
@@ -0,0 +1,501 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module ActionPolicy
7
+ module AuthZEN
8
+ class Error < StandardError; end
9
+ class TransportError < Error; end
10
+ class InvalidResponse < Error; end
11
+ class UnsupportedEndpoint < Error; end
12
+
13
+ class HTTPError < Error
14
+ attr_reader :status
15
+
16
+ def initialize(status)
17
+ @status = status
18
+ super("AuthZEN request returned HTTP #{status}")
19
+ end
20
+ end
21
+
22
+ class Client
23
+ DEFAULT_PATHS = {
24
+ evaluation: "/access/v1/evaluation",
25
+ evaluations: "/access/v1/evaluations",
26
+ search_subject: "/access/v1/search/subject",
27
+ search_resource: "/access/v1/search/resource",
28
+ search_action: "/access/v1/search/action"
29
+ }.freeze
30
+
31
+ METADATA_ENDPOINTS = {
32
+ evaluation: "access_evaluation_endpoint",
33
+ evaluations: "access_evaluations_endpoint",
34
+ search_subject: "search_subject_endpoint",
35
+ search_resource: "search_resource_endpoint",
36
+ search_action: "search_action_endpoint"
37
+ }.freeze
38
+
39
+ EVALUATION_SEMANTICS = %w[execute_all deny_on_first_deny permit_on_first_permit].freeze
40
+
41
+ attr_reader :metadata
42
+
43
+ def self.discover(base_url:, metadata_url: nil, trusted_origins: [], **options)
44
+ unless (options.keys & %i[endpoint endpoints metadata discovered]).empty?
45
+ raise ArgumentError, "discovery endpoints must come from PDP metadata"
46
+ end
47
+ new(base_url: base_url, metadata_url: metadata_url, trusted_origins: trusted_origins, **options).tap(&:configuration)
48
+ end
49
+
50
+ def initialize(endpoint: nil, base_url: nil, endpoints: {}, metadata_url: nil,
51
+ headers: {}, open_timeout: 2, read_timeout: 5, ca_file: nil, trusted_origins: [], allow_http: false)
52
+ raise ArgumentError, "base_url or endpoint is required" if endpoint.nil? && base_url.nil?
53
+
54
+ @allow_http = allow_http == true
55
+ @base_url = base_url.nil? ? nil : parse_base_url(base_url, "base_url")
56
+ @pdp_identifier = base_url&.dup&.freeze
57
+ @metadata_url = metadata_url.nil? ? nil : parse_endpoint(metadata_url, "metadata_url")
58
+ @endpoints = {}
59
+ @endpoints[:evaluation] = parse_endpoint(endpoint, "endpoint") unless endpoint.nil?
60
+ endpoints.each do |operation, value|
61
+ key = operation.to_sym
62
+ raise ArgumentError, "unknown AuthZEN endpoint #{operation.inspect}" unless DEFAULT_PATHS.key?(key)
63
+
64
+ @endpoints[key] = parse_endpoint(value, "endpoints[#{operation.inspect}]")
65
+ end
66
+
67
+ @headers = headers.dup.freeze
68
+ @open_timeout = positive_timeout(open_timeout)
69
+ @read_timeout = positive_timeout(read_timeout)
70
+ @ca_file = ca_file
71
+ @metadata = nil
72
+ @trusted_origins = trusted_origins.map { |origin| normalize_origin(origin) }.freeze
73
+ @discovered = false
74
+ rescue URI::InvalidURIError
75
+ raise ArgumentError, "AuthZEN URLs must be absolute HTTP(S) URLs"
76
+ end
77
+
78
+ def evaluate(subject:, action:, resource:, context: nil)
79
+ result = post(:evaluation, evaluation_payload(subject: subject, action: action, resource: resource, context: context))
80
+ validate_decision(result, "AuthZEN response")
81
+ result
82
+ end
83
+
84
+ def allowed?(**request)
85
+ evaluate(**request).fetch("decision")
86
+ end
87
+
88
+ def evaluations(evaluations: nil, subject: nil, action: nil, resource: nil, context: nil, options: nil)
89
+ payload = evaluations_payload(evaluations: evaluations, subject: subject, action: action,
90
+ resource: resource, context: context, options: options)
91
+ result = post(:evaluations, payload)
92
+ single_request = evaluations.nil? || evaluations.empty?
93
+ count = single_request ? 1 : evaluations.length
94
+ semantic = payload.dig("options", "evaluations_semantic") || "execute_all"
95
+ validate_evaluations_response(result, count, semantic, single_request: single_request)
96
+ result
97
+ end
98
+
99
+ def search_subjects(subject:, action:, resource:, context: nil, page: nil)
100
+ search(:search_subject, :subject, subject: subject, action: action, resource: resource,
101
+ context: context, page: page)
102
+ end
103
+
104
+ def search_resources(subject:, action:, resource:, context: nil, page: nil)
105
+ search(:search_resource, :resource, subject: subject, action: action, resource: resource,
106
+ context: context, page: page)
107
+ end
108
+
109
+ def search_actions(subject:, resource:, context: nil, page: nil)
110
+ search(:search_action, :action, subject: subject, resource: resource, context: context, page: page)
111
+ end
112
+
113
+ def each_subject(**request)
114
+ return enum_for(:each_subject, **request) unless block_given?
115
+
116
+ each_search(:search_subjects, request) { |entity| yield entity }
117
+ end
118
+
119
+ def each_resource(**request)
120
+ return enum_for(:each_resource, **request) unless block_given?
121
+
122
+ each_search(:search_resources, request) { |entity| yield entity }
123
+ end
124
+
125
+ def each_action(**request)
126
+ return enum_for(:each_action, **request) unless block_given?
127
+
128
+ each_search(:search_actions, request) { |entity| yield entity }
129
+ end
130
+
131
+ def configuration
132
+ base = base_url
133
+ raise ArgumentError, "configuration discovery requires base_url" if base.nil?
134
+
135
+ response = get(metadata_uri(base))
136
+ raise HTTPError, response.code.to_i unless response.code == "200"
137
+ validate_json_content_type(response, "AuthZEN metadata response")
138
+
139
+ metadata = parse_json_object(response.body, "Invalid AuthZEN metadata JSON response")
140
+ validate_metadata(metadata, base)
141
+ endpoints = METADATA_ENDPOINTS.each_with_object({}) do |(operation, key), configured|
142
+ configured[operation] = parse_endpoint(metadata.fetch(key), key) if metadata.key?(key)
143
+ end
144
+ @endpoints = endpoints
145
+ @discovered = true
146
+ @metadata = metadata
147
+ end
148
+
149
+ private
150
+
151
+ attr_reader :base_url
152
+
153
+ def positive_timeout(value)
154
+ unless value.is_a?(Numeric) && value.positive? && value.finite?
155
+ raise ArgumentError, "timeouts must be positive finite numbers"
156
+ end
157
+ value
158
+ end
159
+
160
+ def evaluation_payload(subject:, action:, resource:, context:)
161
+ payload = {
162
+ "subject" => object(subject, %w[type id], "subject"),
163
+ "action" => object(action_value(action), %w[name], "action"),
164
+ "resource" => object(resource, %w[type id], "resource")
165
+ }
166
+ payload["context"] = hash_object(context, "context") unless context.nil?
167
+ payload
168
+ end
169
+
170
+ def evaluations_payload(evaluations:, subject:, action:, resource:, context:, options:)
171
+ payload = {}
172
+ payload["subject"] = object(subject, %w[type id], "subject") unless subject.nil?
173
+ payload["action"] = object(action_value(action), %w[name], "action") unless action.nil?
174
+ payload["resource"] = object(resource, %w[type id], "resource") unless resource.nil?
175
+ payload["context"] = hash_object(context, "context") unless context.nil?
176
+ payload["options"] = hash_object(options, "options") unless options.nil?
177
+
178
+ semantic = payload.dig("options", "evaluations_semantic") || "execute_all"
179
+ unless EVALUATION_SEMANTICS.include?(semantic)
180
+ raise ArgumentError, "options.evaluations_semantic must be execute_all, deny_on_first_deny, or permit_on_first_permit"
181
+ end
182
+
183
+ unless evaluations.nil? || evaluations.is_a?(Array)
184
+ raise ArgumentError, "evaluations must be an array"
185
+ end
186
+
187
+ if evaluations.nil? || evaluations.empty?
188
+ %w[subject action resource].each do |key|
189
+ raise ArgumentError, "#{key} is required when evaluations is absent or empty" unless payload.key?(key)
190
+ end
191
+ payload["evaluations"] = [] unless evaluations.nil?
192
+ return payload
193
+ end
194
+
195
+ payload["evaluations"] = evaluations.each_with_index.map do |evaluation, index|
196
+ normalized = hash_object(evaluation, "evaluations[#{index}]")
197
+ item = {}
198
+ item["subject"] = object(normalized["subject"], %w[type id], "evaluations[#{index}].subject") if normalized.key?("subject")
199
+ item["action"] = object(action_value(normalized["action"]), %w[name], "evaluations[#{index}].action") if normalized.key?("action")
200
+ item["resource"] = object(normalized["resource"], %w[type id], "evaluations[#{index}].resource") if normalized.key?("resource")
201
+ item["context"] = hash_object(normalized["context"], "evaluations[#{index}].context") if normalized.key?("context")
202
+
203
+ effective = payload.merge(item)
204
+ %w[subject action resource].each do |key|
205
+ raise ArgumentError, "evaluations[#{index}].#{key} or top-level #{key} is required" unless effective.key?(key)
206
+ end
207
+ item
208
+ end
209
+ payload
210
+ end
211
+
212
+ def action_value(value)
213
+ value.is_a?(String) ? {"name" => value} : value
214
+ end
215
+
216
+ def object(value, required, label)
217
+ normalized = hash_object(value, label)
218
+ required.each do |key|
219
+ unless normalized[key].is_a?(String)
220
+ raise ArgumentError, "#{label}.#{key} must be a string"
221
+ end
222
+ end
223
+ if normalized.key?("properties") && !normalized["properties"].is_a?(Hash)
224
+ raise ArgumentError, "#{label}.properties must be an object"
225
+ end
226
+ normalized
227
+ end
228
+
229
+ def hash_object(value, label)
230
+ raise ArgumentError, "#{label} must be an object" unless value.is_a?(Hash)
231
+
232
+ value.transform_keys(&:to_s)
233
+ end
234
+
235
+ def page_object(value)
236
+ page = hash_object(value, "page")
237
+ unless (page.keys - %w[token limit properties]).empty?
238
+ raise ArgumentError, "unsupported page extension; use page.properties for implementation-specific attributes"
239
+ end
240
+ raise ArgumentError, "page.token must be a string" if page.key?("token") && !page["token"].is_a?(String)
241
+ unless !page.key?("limit") || (page["limit"].is_a?(Integer) && page["limit"] >= 0)
242
+ raise ArgumentError, "page.limit must be a non-negative integer"
243
+ end
244
+ raise ArgumentError, "page.properties must be an object" if page.key?("properties") && !page["properties"].is_a?(Hash)
245
+
246
+ page
247
+ end
248
+
249
+ def post(operation, payload)
250
+ uri = endpoint_for(operation)
251
+ request = Net::HTTP::Post.new(uri, @headers)
252
+ request["Content-Type"] = "application/json"
253
+ request["Accept"] = "application/json"
254
+ request["X-Request-ID"] ||= SecureRandom.uuid
255
+ request.body = JSON.generate(payload)
256
+ response = perform(uri, request)
257
+ raise HTTPError, response.code.to_i unless response.code == "200"
258
+ validate_json_content_type(response, "AuthZEN response")
259
+
260
+ parse_json_object(response.body, "Invalid AuthZEN JSON response")
261
+ end
262
+
263
+ def get(uri)
264
+ request = Net::HTTP::Get.new(uri, @headers)
265
+ request["Accept"] = "application/json"
266
+ request["X-Request-ID"] ||= SecureRandom.uuid
267
+ perform(uri, request)
268
+ end
269
+
270
+ def parse_json_object(body, message)
271
+ result = JSON.parse(body)
272
+ raise InvalidResponse, "#{message}: root must be an object" unless result.is_a?(Hash)
273
+
274
+ result
275
+ rescue JSON::ParserError, TypeError => error
276
+ raise InvalidResponse, message, cause: error
277
+ end
278
+
279
+ def perform(uri, request)
280
+ http = Net::HTTP.new(uri.host, uri.port)
281
+ http.use_ssl = uri.scheme == "https"
282
+ http.ca_file = @ca_file if @ca_file
283
+ http.open_timeout = @open_timeout
284
+ http.read_timeout = @read_timeout
285
+ http.write_timeout = @read_timeout
286
+ http.max_retries = 0
287
+ http.start { |connection| connection.request(request) }
288
+ rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::ProtocolError => error
289
+ raise TransportError, "AuthZEN request failed (#{error.class})", cause: error
290
+ end
291
+
292
+ def endpoint_for(operation)
293
+ return @endpoints.fetch(operation) if @endpoints.key?(operation)
294
+ if @discovered
295
+ metadata_key = METADATA_ENDPOINTS.fetch(operation)
296
+ raise UnsupportedEndpoint, "AuthZEN #{operation} endpoint is unsupported by discovered metadata (missing #{metadata_key})"
297
+ end
298
+ raise ArgumentError, "#{operation} endpoint requires base_url or explicit endpoints[:#{operation}]" if @base_url.nil?
299
+
300
+ append_path(@base_url, DEFAULT_PATHS.fetch(operation))
301
+ end
302
+
303
+ def parse_endpoint(value, label)
304
+ raise ArgumentError, "#{label} must be a URL string" unless value.is_a?(String)
305
+ uri = URI.parse(value)
306
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty? && !uri.userinfo && !uri.fragment
307
+ raise ArgumentError, "#{label} must be an absolute HTTP(S) URL without credentials or fragment"
308
+ end
309
+ if uri.scheme != "https" && !@allow_http
310
+ raise ArgumentError, "#{label} requires HTTPS; allow_http: true is for local testing"
311
+ end
312
+
313
+ uri
314
+ end
315
+
316
+ def parse_base_url(value, label)
317
+ uri = parse_endpoint(value, label)
318
+ raise ArgumentError, "#{label} must not contain a query" if uri.query
319
+
320
+ uri
321
+ end
322
+
323
+ def append_path(uri, path)
324
+ copy = uri.dup
325
+ prefix = copy.path.to_s.sub(%r{/+\z}, "")
326
+ copy.path = "#{prefix}#{path}"
327
+ copy.query = nil
328
+ copy
329
+ end
330
+
331
+ def metadata_uri(base)
332
+ return @metadata_url if @metadata_url
333
+
334
+ copy = base.dup
335
+ path = copy.path.to_s
336
+ copy.path = "/.well-known/authzen-configuration#{path}"
337
+ copy.query = nil
338
+ copy
339
+ end
340
+
341
+ def validate_decision(result, label)
342
+ unless result.is_a?(Hash) && [true, false].include?(result["decision"])
343
+ raise InvalidResponse, "#{label} must contain a boolean decision"
344
+ end
345
+ if result.key?("context") && !result["context"].is_a?(Hash)
346
+ raise InvalidResponse, "#{label} context must be an object"
347
+ end
348
+ end
349
+
350
+ def validate_evaluations_response(result, expected_count, semantic, single_request:)
351
+ if single_request && !result.key?("evaluations")
352
+ validate_decision(result, "AuthZEN evaluations response")
353
+ return
354
+ end
355
+
356
+ evaluations = result["evaluations"]
357
+ raise InvalidResponse, "AuthZEN evaluations response must contain evaluations array" unless evaluations.is_a?(Array)
358
+ evaluations.each_with_index { |decision, index| validate_decision(decision, "AuthZEN evaluations[#{index}]") }
359
+ validate_evaluations_cardinality(evaluations, expected_count, semantic)
360
+ end
361
+
362
+ def validate_evaluations_cardinality(evaluations, expected_count, semantic)
363
+ actual_count = evaluations.length
364
+ case semantic
365
+ when "execute_all"
366
+ raise InvalidResponse, "AuthZEN evaluations response must contain #{expected_count} results" unless actual_count == expected_count
367
+ when "deny_on_first_deny"
368
+ validate_short_circuit(evaluations, expected_count, false, semantic)
369
+ when "permit_on_first_permit"
370
+ validate_short_circuit(evaluations, expected_count, true, semantic)
371
+ end
372
+ end
373
+
374
+ def validate_short_circuit(evaluations, expected_count, stop_decision, semantic)
375
+ raise InvalidResponse, "AuthZEN evaluations response must not be empty" if evaluations.empty?
376
+ raise InvalidResponse, "AuthZEN evaluations response has too many results" if evaluations.length > expected_count
377
+ evaluations[0...-1].each do |decision|
378
+ if decision["decision"] == stop_decision
379
+ raise InvalidResponse, "AuthZEN #{semantic} response short-circuited after an invalid decision"
380
+ end
381
+ end
382
+ unless evaluations.length == expected_count || evaluations.last["decision"] == stop_decision
383
+ raise InvalidResponse, "AuthZEN #{semantic} response must stop on #{stop_decision}"
384
+ end
385
+ end
386
+
387
+ def search(operation, result_kind, **request)
388
+ payload = search_payload(operation, **request)
389
+ result = post(operation, payload)
390
+ expected_type = payload.dig(result_kind.to_s, "type") unless result_kind == :action
391
+ validate_search_response(result, result_kind, expected_type)
392
+ result
393
+ end
394
+
395
+ def search_payload(operation, subject: nil, action: nil, resource: nil, context: nil, page: nil)
396
+ payload = {
397
+ "subject" => object(subject, operation == :search_subject ? %w[type] : %w[type id], "subject"),
398
+ "resource" => object(resource, operation == :search_resource ? %w[type] : %w[type id], "resource")
399
+ }
400
+ payload["action"] = object(action_value(action), %w[name], "action") unless operation == :search_action
401
+ payload["context"] = hash_object(context, "context") unless context.nil?
402
+ payload["page"] = page_object(page) unless page.nil?
403
+ payload
404
+ end
405
+
406
+ def validate_search_response(result, result_kind, expected_type)
407
+ results = result["results"]
408
+ raise InvalidResponse, "AuthZEN search response must contain results array" unless results.is_a?(Array)
409
+
410
+ results.each_with_index do |entity, index|
411
+ object(entity, result_kind == :action ? %w[name] : %w[type id], "results[#{index}]")
412
+ if expected_type && entity["type"] != expected_type
413
+ raise InvalidResponse, "AuthZEN search returned a different entity type"
414
+ end
415
+ end
416
+ raise InvalidResponse, "AuthZEN search response context must be an object" if result.key?("context") && !result["context"].is_a?(Hash)
417
+ validate_response_page(result["page"]) if result.key?("page")
418
+ rescue ArgumentError => error
419
+ raise InvalidResponse, error.message
420
+ end
421
+
422
+ def validate_response_page(page)
423
+ raise InvalidResponse, "AuthZEN search response page must be an object" unless page.is_a?(Hash)
424
+ raise InvalidResponse, "AuthZEN search response page.next_token must be a string" unless page["next_token"].is_a?(String)
425
+ if page.key?("properties") && !page["properties"].is_a?(Hash)
426
+ raise InvalidResponse, "AuthZEN search response page.properties must be an object"
427
+ end
428
+ %w[count total].each do |key|
429
+ next unless page.key?(key)
430
+ raise InvalidResponse, "AuthZEN search response page.#{key} must be a non-negative integer" unless page[key].is_a?(Integer) && page[key] >= 0
431
+ end
432
+ end
433
+
434
+ def each_search(method_name, request)
435
+ snapshot = deep_copy(request)
436
+ seen_tokens = {}
437
+ loop do
438
+ response = public_send(method_name, **snapshot)
439
+ response.fetch("results").each { |entity| yield entity }
440
+ next_token = response.fetch("page", {})["next_token"]
441
+ break if next_token.nil? || next_token.empty?
442
+ raise InvalidResponse, "AuthZEN pagination repeated next_token" if seen_tokens[next_token]
443
+
444
+ seen_tokens[next_token] = true
445
+ page = snapshot[:page] || snapshot["page"] || {}
446
+ page = deep_copy(page).merge(token: next_token)
447
+ snapshot = snapshot.merge(page: page)
448
+ end
449
+ end
450
+
451
+ def deep_copy(value)
452
+ Marshal.load(Marshal.dump(value))
453
+ end
454
+
455
+ def validate_metadata(metadata, base)
456
+ pdp = metadata["policy_decision_point"]
457
+ unless pdp.is_a?(String) && pdp == @pdp_identifier
458
+ raise InvalidResponse, "AuthZEN metadata policy_decision_point must exactly match #{@pdp_identifier}"
459
+ end
460
+
461
+ parse_base_url(pdp, "policy_decision_point metadata")
462
+ evaluation = metadata["access_evaluation_endpoint"]
463
+ raise InvalidResponse, "AuthZEN metadata requires access_evaluation_endpoint" unless evaluation.is_a?(String)
464
+
465
+ METADATA_ENDPOINTS.each_value do |key|
466
+ next unless metadata.key?(key)
467
+ raise InvalidResponse, "AuthZEN metadata #{key} must be a string" unless metadata[key].is_a?(String)
468
+ uri = parse_endpoint(metadata[key], "#{key} metadata")
469
+ unless same_origin?(uri, base) || @trusted_origins.include?(origin(uri))
470
+ raise InvalidResponse, "AuthZEN metadata endpoint #{key} is not on a trusted origin"
471
+ end
472
+ end
473
+
474
+ if metadata.key?("capabilities")
475
+ unless metadata["capabilities"].is_a?(Array) && metadata["capabilities"].all? { |capability| capability.is_a?(String) }
476
+ raise InvalidResponse, "AuthZEN metadata capabilities must be an array of strings"
477
+ end
478
+ end
479
+ rescue ArgumentError, URI::InvalidURIError => error
480
+ raise InvalidResponse, error.message
481
+ end
482
+
483
+ def validate_json_content_type(response, label)
484
+ content_type = response["content-type"].to_s.split(";", 2).first.to_s.strip.downcase
485
+ raise InvalidResponse, "#{label} Content-Type must be application/json" unless content_type == "application/json"
486
+ end
487
+
488
+ def same_origin?(left, right)
489
+ origin(left) == origin(right)
490
+ end
491
+
492
+ def normalize_origin(value)
493
+ origin(parse_endpoint(value, "trusted_origins"))
494
+ end
495
+
496
+ def origin(uri)
497
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
498
+ end
499
+ end
500
+ end
501
+ end
@@ -0,0 +1,51 @@
1
+ module ActionPolicy
2
+ module AuthZEN
3
+ module Policy
4
+ def self.included(base)
5
+ base.authorize :authzen_client
6
+ end
7
+
8
+ private
9
+
10
+ def authzen_configuration
11
+ authzen_client.configuration
12
+ end
13
+
14
+ def authzen_evaluate(**request)
15
+ authzen_client.evaluate(**request)
16
+ end
17
+
18
+ def authzen_evaluations(**request)
19
+ authzen_client.evaluations(**request)
20
+ end
21
+
22
+ def authzen_search_subjects(**request)
23
+ authzen_client.search_subjects(**request)
24
+ end
25
+
26
+ def authzen_search_resources(**request)
27
+ authzen_client.search_resources(**request)
28
+ end
29
+
30
+ def authzen_search_actions(**request)
31
+ authzen_client.search_actions(**request)
32
+ end
33
+
34
+ def authzen_each_subject(**request, &block)
35
+ authzen_client.each_subject(**request, &block)
36
+ end
37
+
38
+ def authzen_each_resource(**request, &block)
39
+ authzen_client.each_resource(**request, &block)
40
+ end
41
+
42
+ def authzen_each_action(**request, &block)
43
+ authzen_client.each_action(**request, &block)
44
+ end
45
+
46
+ def authzen_allowed?(subject:, action:, resource:, context: nil)
47
+ authzen_client.allowed?(subject: subject, action: action, resource: resource, context: context)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ module ActionPolicy
2
+ module AuthZEN
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ require "action_policy"
2
+ require_relative "authzen/version"
3
+ require_relative "authzen/client"
4
+ require_relative "authzen/policy"
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_policy-authzen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - kajisha
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: action_policy
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.7.7
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.7.7
27
+ - !ruby/object:Gem::Dependency
28
+ name: net-http
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0.3'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '1.0'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0.3'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: json
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.6'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '3.0'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '2.6'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.0'
67
+ description:
68
+ email:
69
+ executables: []
70
+ extensions: []
71
+ extra_rdoc_files: []
72
+ files:
73
+ - LICENSE.txt
74
+ - README.md
75
+ - docs/conformance.md
76
+ - docs/official-scenarios.md
77
+ - lib/action_policy/authzen.rb
78
+ - lib/action_policy/authzen/client.rb
79
+ - lib/action_policy/authzen/policy.rb
80
+ - lib/action_policy/authzen/version.rb
81
+ homepage: https://github.com/kajisha/action_policy-authzen
82
+ licenses:
83
+ - MIT
84
+ metadata: {}
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.3'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.5.20
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: AuthZEN authorization APIs for Action Policy
104
+ test_files: []