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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/docs/conformance.md +81 -0
- data/docs/official-scenarios.md +103 -0
- data/lib/action_policy/authzen/client.rb +501 -0
- data/lib/action_policy/authzen/policy.rb +51 -0
- data/lib/action_policy/authzen/version.rb +5 -0
- data/lib/action_policy/authzen.rb +4 -0
- metadata +104 -0
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`.
|
data/docs/conformance.md
ADDED
|
@@ -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
|
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: []
|