openehr-rails 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +55 -0
- data/app/controllers/openehr_rails/application_controller.rb +37 -0
- data/app/controllers/openehr_rails/fhir_controller.rb +5 -0
- data/app/controllers/openehr_rails/openehr_api/compositions_controller.rb +5 -0
- data/app/controllers/openehr_rails/openehr_api/ehrs_controller.rb +5 -0
- data/app/controllers/openehr_rails/queries_controller.rb +5 -0
- data/app/controllers/openehr_rails/templates_controller.rb +1 -0
- data/lib/generators/openehr/install/templates/initializers/openehr.rb +31 -0
- data/lib/openehr-rails/version.rb +1 -1
- data/lib/openehr_rails/opt/remote_fetcher.rb +37 -0
- data/lib/openehr_rails.rb +25 -0
- data/spec/generators/openehr/install/install_generator_spec.rb +2 -0
- data/spec/openehr_rails/authentication_spec.rb +141 -0
- data/spec/openehr_rails/opt/remote_fetcher_spec.rb +35 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8420a812bd6c4b3f95a403f4804fa58d660a1aa6d736d106bc0dc57a8b63e1ee
|
|
4
|
+
data.tar.gz: df4f5629f4915f03c4a07b944a168a1c53d08da043e07cbc6cf6d705d036f017
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e39f211e2243b0ac74aff1408136652ba0b4f829a8bc28a92a842ba3e1636955d4ea9fcdfe6ee0e0351b9f3c7c5bd763139478574a5d5acf19c8f8fb9f62a5a
|
|
7
|
+
data.tar.gz: 4a8e6beb226a33825df8cd1d762b546cbaf4ca39877d2bda72ab8e81978a58fe57ceefa75ef1d372c609d495fb6d35adc65016592078d10f746be0f7ca041ed7
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-08-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `OpenehrRails.authenticate_with`: a pluggable authentication hook, run
|
|
12
|
+
as a `before_action` ahead of every engine action (admin UI, AQL
|
|
13
|
+
console, patient timeline, the `/v1` openEHR REST API, and the `/fhir`
|
|
14
|
+
facade). Host apps wire in their own mechanism (Devise, HTTP token,
|
|
15
|
+
anything) with the controller as context. Per-surface differentiation
|
|
16
|
+
via `openehr_access_scope` (`:admin`/`:rest_api`/`:fhir`).
|
|
17
|
+
- `OpenehrRails.allow_unauthenticated_access`: explicit opt-out for
|
|
18
|
+
intentionally-open deployments.
|
|
19
|
+
|
|
20
|
+
### Changed (behavior change -- read before upgrading)
|
|
21
|
+
- **The engine now denies all requests with `403 Forbidden` outside the
|
|
22
|
+
`development` and `test` environments unless `authenticate_with` or
|
|
23
|
+
`allow_unauthenticated_access` is configured.** Previously every
|
|
24
|
+
engine route (including the full openEHR REST API and the FHIR
|
|
25
|
+
facade's create endpoint) was open with no access control whatsoever.
|
|
26
|
+
Apps running 0.3.0 in production must configure one of the two before
|
|
27
|
+
upgrading, or the engine will start responding 403 to everything.
|
|
28
|
+
- `RemoteFetcher` now also rejects loopback/private/link-local (including
|
|
29
|
+
cloud metadata) addresses, both on the initial URL and on every
|
|
30
|
+
redirect hop, closing an SSRF gap (previously only the URL scheme was
|
|
31
|
+
validated).
|
|
32
|
+
- Every generic `rescue_from StandardError` handler across the engine's
|
|
33
|
+
controllers now logs the exception (class, message, and a short
|
|
34
|
+
backtrace) via `Rails.logger` before responding -- previously errors
|
|
35
|
+
were rendered to the client and left no server-side trace at all.
|
|
36
|
+
|
|
8
37
|
## [0.3.0] - 2026-08-13
|
|
9
38
|
|
|
10
39
|
### Added
|
data/README.md
CHANGED
|
@@ -65,6 +65,61 @@ enabled in the development environment only. Override with:
|
|
|
65
65
|
OpenehrRails.enable_runtime_scaffolding = true # or false
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
### Authentication
|
|
69
|
+
|
|
70
|
+
Everything the engine serves — the template admin UI, the AQL console,
|
|
71
|
+
the patient timeline, the openEHR REST API (`/openehr/v1`) and the FHIR
|
|
72
|
+
facade (`/openehr/fhir`) — handles clinical data, so outside the
|
|
73
|
+
`development` and `test` environments the engine is **closed by
|
|
74
|
+
default**: every request gets `403 Forbidden` until you configure an
|
|
75
|
+
authentication hook.
|
|
76
|
+
|
|
77
|
+
The hook runs as a `before_action` inside the engine controller handling
|
|
78
|
+
the request (`instance_exec`'d, so it can use `request`, `render`,
|
|
79
|
+
`redirect_to`, and any helper your app mixes into `ActionController::Base`).
|
|
80
|
+
Deny by rendering or redirecting — a hook that raises would be caught
|
|
81
|
+
and reported as a misleading error by some engine controllers'
|
|
82
|
+
`rescue_from StandardError`.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# config/initializers/openehr.rb
|
|
86
|
+
|
|
87
|
+
# Devise:
|
|
88
|
+
OpenehrRails.authenticate_with = -> { authenticate_user! }
|
|
89
|
+
|
|
90
|
+
# Bearer token:
|
|
91
|
+
OpenehrRails.authenticate_with = lambda do
|
|
92
|
+
authenticate_or_request_with_http_token do |token, _options|
|
|
93
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
94
|
+
token, Rails.application.credentials.openehr_api_token.to_s
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
To use a different mechanism per surface, branch on `openehr_access_scope`
|
|
101
|
+
(`:admin` — template UI / AQL console / timeline, `:rest_api` — `/v1`,
|
|
102
|
+
`:fhir` — the FHIR facade):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
OpenehrRails.authenticate_with = lambda do
|
|
106
|
+
case openehr_access_scope
|
|
107
|
+
when :admin then authenticate_user!
|
|
108
|
+
else authenticate_or_request_with_http_token { |t, _| valid_api_token?(t) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
To intentionally run without authentication (e.g. behind a reverse
|
|
114
|
+
proxy that already authenticates, or a network-isolated internal app):
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
OpenehrRails.allow_unauthenticated_access = true
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Note: the JSON API controllers (`/v1`, `/fhir`) skip CSRF protection, so
|
|
121
|
+
prefer token authentication over session cookies for those.
|
|
122
|
+
|
|
68
123
|
### HL7 FHIR R5 facade
|
|
69
124
|
|
|
70
125
|
The engine also serves a FHIR R5 API under `<mount>/fhir`
|
|
@@ -3,5 +3,42 @@
|
|
|
3
3
|
module OpenehrRails
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
5
|
layout 'openehr_rails/application'
|
|
6
|
+
|
|
7
|
+
before_action :authenticate_openehr_access!
|
|
8
|
+
|
|
9
|
+
# Which engine surface this request targets, so a single
|
|
10
|
+
# OpenehrRails.authenticate_with hook can differentiate if desired:
|
|
11
|
+
# :admin - template management UI, AQL console, patient timeline
|
|
12
|
+
# :rest_api - openEHR REST API under /v1
|
|
13
|
+
# :fhir - HL7 FHIR R5 facade under /fhir
|
|
14
|
+
def openehr_access_scope
|
|
15
|
+
:admin
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def authenticate_openehr_access!
|
|
21
|
+
hook = OpenehrRails.authenticate_with
|
|
22
|
+
return instance_exec(&hook) if hook
|
|
23
|
+
return if OpenehrRails.unauthenticated_access_allowed?
|
|
24
|
+
|
|
25
|
+
deny_openehr_access
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def log_openehr_error(error)
|
|
29
|
+
Rails.logger&.error(
|
|
30
|
+
"[OpenehrRails] #{error.class}: #{error.message}\n#{Array(error.backtrace).first(10).join("\n")}"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def deny_openehr_access
|
|
35
|
+
message = 'openEHR engine access denied: no authentication configured. ' \
|
|
36
|
+
'Set OpenehrRails.authenticate_with in config/initializers/openehr.rb.'
|
|
37
|
+
if request.format.json?
|
|
38
|
+
render json: { error: message }, status: :forbidden
|
|
39
|
+
else
|
|
40
|
+
render plain: message, status: :forbidden
|
|
41
|
+
end
|
|
42
|
+
end
|
|
6
43
|
end
|
|
7
44
|
end
|
|
@@ -15,6 +15,10 @@ module OpenehrRails
|
|
|
15
15
|
|
|
16
16
|
FHIR_CONTENT_TYPE = 'application/fhir+json'
|
|
17
17
|
|
|
18
|
+
def openehr_access_scope
|
|
19
|
+
:fhir
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
def metadata
|
|
19
23
|
render_fhir OpenehrRails::Fhir::CapabilityStatement.build(base_url: request.base_url)
|
|
20
24
|
end
|
|
@@ -125,6 +129,7 @@ module OpenehrRails
|
|
|
125
129
|
end
|
|
126
130
|
|
|
127
131
|
def render_operation_outcome(error)
|
|
132
|
+
log_openehr_error(error)
|
|
128
133
|
status = case error
|
|
129
134
|
when OpenehrRails::Fhir::Deserializer::UnmappedResource then :unprocessable_entity
|
|
130
135
|
when ActiveRecord::RecordInvalid then :unprocessable_entity
|
|
@@ -20,6 +20,10 @@ module OpenehrRails
|
|
|
20
20
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
21
21
|
rescue_from PreconditionFailed, with: :render_precondition_failed
|
|
22
22
|
|
|
23
|
+
def openehr_access_scope
|
|
24
|
+
:rest_api
|
|
25
|
+
end
|
|
26
|
+
|
|
23
27
|
# POST /v1/ehr/:ehr_id/composition
|
|
24
28
|
def create
|
|
25
29
|
ehr = find_ehr!
|
|
@@ -110,6 +114,7 @@ module OpenehrRails
|
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
def render_error(error)
|
|
117
|
+
log_openehr_error(error)
|
|
113
118
|
render json: { error: error.message }, status: :bad_request
|
|
114
119
|
end
|
|
115
120
|
end
|
|
@@ -11,6 +11,10 @@ module OpenehrRails
|
|
|
11
11
|
rescue_from ActiveRecord::RecordInvalid, with: :render_invalid
|
|
12
12
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
13
13
|
|
|
14
|
+
def openehr_access_scope
|
|
15
|
+
:rest_api
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
# POST /v1/ehr (server-generated ehr_id)
|
|
15
19
|
def create
|
|
16
20
|
ehr = OpenehrRails::Rm::Ehr.create!(ehr_attributes.merge(ehr_id: SecureRandom.uuid))
|
|
@@ -75,6 +79,7 @@ module OpenehrRails
|
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
def render_error(error)
|
|
82
|
+
log_openehr_error(error)
|
|
78
83
|
render json: { error: error.message }, status: :bad_request
|
|
79
84
|
end
|
|
80
85
|
end
|
|
@@ -11,6 +11,10 @@ module OpenehrRails
|
|
|
11
11
|
rescue_from StandardError, with: :render_error
|
|
12
12
|
rescue_from OpenehrRails::Aql::Error, with: :render_aql_error
|
|
13
13
|
|
|
14
|
+
def openehr_access_scope
|
|
15
|
+
action_name == 'execute' ? :rest_api : :admin
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
# GET /query -- HTML console. Executes via fetch against
|
|
15
19
|
# POST /v1/query/aql below, so there is exactly one execution path.
|
|
16
20
|
def show; end
|
|
@@ -50,6 +54,7 @@ module OpenehrRails
|
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def render_error(error)
|
|
57
|
+
log_openehr_error(error)
|
|
53
58
|
render json: { error: error.message }, status: :bad_request
|
|
54
59
|
end
|
|
55
60
|
end
|
|
@@ -20,3 +20,34 @@ require 'openehr_rails'
|
|
|
20
20
|
# OpenehrRails.default_category = %w[433 event] # [code, value]
|
|
21
21
|
# OpenehrRails.default_composer_name = 'unknown'
|
|
22
22
|
# OpenehrRails.default_encoding = 'UTF-8'
|
|
23
|
+
|
|
24
|
+
# Authentication (REQUIRED before deploying outside development/test):
|
|
25
|
+
# the engine (admin UI, AQL console, /v1 REST API, /fhir facade) serves
|
|
26
|
+
# clinical data, so it denies every request with 403 in any environment
|
|
27
|
+
# other than development/test until you configure a hook here. The hook
|
|
28
|
+
# runs as a before_action inside the engine controller handling the
|
|
29
|
+
# request (instance_exec'd) -- deny by rendering/redirecting, not
|
|
30
|
+
# raising, since some engine controllers rescue_from StandardError.
|
|
31
|
+
#
|
|
32
|
+
# Devise:
|
|
33
|
+
# OpenehrRails.authenticate_with = -> { authenticate_user! }
|
|
34
|
+
#
|
|
35
|
+
# Bearer token:
|
|
36
|
+
# OpenehrRails.authenticate_with = lambda do
|
|
37
|
+
# authenticate_or_request_with_http_token do |token, _options|
|
|
38
|
+
# ActiveSupport::SecurityUtils.secure_compare(
|
|
39
|
+
# token, Rails.application.credentials.openehr_api_token.to_s
|
|
40
|
+
# )
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# Different mechanism per surface (openehr_access_scope is :admin,
|
|
45
|
+
# :rest_api or :fhir):
|
|
46
|
+
# OpenehrRails.authenticate_with = lambda do
|
|
47
|
+
# openehr_access_scope == :admin ? authenticate_user! : authenticate_or_request_with_http_token { |t, _| valid_api_token?(t) }
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# Explicit opt-out for intentionally-open deployments (e.g. behind a
|
|
51
|
+
# reverse proxy that already authenticates, network-isolated internal
|
|
52
|
+
# apps) -- NOT recommended for anything reachable outside your network:
|
|
53
|
+
# OpenehrRails.allow_unauthenticated_access = true
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'uri'
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
require 'resolv'
|
|
5
7
|
require 'openehr_rails/opt'
|
|
6
8
|
|
|
7
9
|
module OpenehrRails
|
|
@@ -20,6 +22,21 @@ module OpenehrRails
|
|
|
20
22
|
READ_TIMEOUT = 20
|
|
21
23
|
ALLOWED_SCHEMES = %w[http https].freeze
|
|
22
24
|
|
|
25
|
+
# Loopback, private, and link-local ranges (the latter includes the
|
|
26
|
+
# 169.254.169.254 cloud-metadata endpoint AWS/GCP/Azure all use).
|
|
27
|
+
# Checked against every hop (initial URL and every redirect target,
|
|
28
|
+
# since parse_uri runs on each via #get), not just the first.
|
|
29
|
+
BLOCKED_IP_RANGES = [
|
|
30
|
+
IPAddr.new('127.0.0.0/8'),
|
|
31
|
+
IPAddr.new('10.0.0.0/8'),
|
|
32
|
+
IPAddr.new('172.16.0.0/12'),
|
|
33
|
+
IPAddr.new('192.168.0.0/16'),
|
|
34
|
+
IPAddr.new('169.254.0.0/16'),
|
|
35
|
+
IPAddr.new('::1/128'),
|
|
36
|
+
IPAddr.new('fc00::/7'),
|
|
37
|
+
IPAddr.new('fe80::/10')
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
23
40
|
def self.fetch(url)
|
|
24
41
|
new(url).fetch
|
|
25
42
|
end
|
|
@@ -58,10 +75,30 @@ module OpenehrRails
|
|
|
58
75
|
unless ALLOWED_SCHEMES.include?(uri.scheme)
|
|
59
76
|
raise FetchError, "unsupported URL scheme: #{uri.scheme.inspect} (must be http/https)"
|
|
60
77
|
end
|
|
78
|
+
if blocked_host?(uri.host)
|
|
79
|
+
raise FetchError, "refusing to fetch #{url}: host resolves to a private/internal/link-local address"
|
|
80
|
+
end
|
|
61
81
|
|
|
62
82
|
uri
|
|
63
83
|
end
|
|
64
84
|
|
|
85
|
+
# True if `host` (a literal IP, a bracketed IPv6 literal, or a
|
|
86
|
+
# hostname to resolve) is, or resolves to, a loopback/private/
|
|
87
|
+
# link-local address. Fails open on an unresolvable hostname --
|
|
88
|
+
# Net::HTTP will then fail on its own DNS lookup, converted to a
|
|
89
|
+
# FetchError by #get's rescue clause.
|
|
90
|
+
def blocked_host?(host)
|
|
91
|
+
literal = literal_ip(host)
|
|
92
|
+
addresses = literal ? [literal] : Resolv.getaddresses(host).filter_map { |ip| literal_ip(ip) }
|
|
93
|
+
addresses.any? { |address| BLOCKED_IP_RANGES.any? { |range| range.include?(address) } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def literal_ip(host)
|
|
97
|
+
IPAddr.new(host)
|
|
98
|
+
rescue IPAddr::Error
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
65
102
|
def request(uri)
|
|
66
103
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
67
104
|
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
|
data/lib/openehr_rails.rb
CHANGED
|
@@ -63,4 +63,29 @@ module OpenehrRails
|
|
|
63
63
|
|
|
64
64
|
defined?(::Rails.env) && ::Rails.env.development?
|
|
65
65
|
end
|
|
66
|
+
|
|
67
|
+
# Authentication hook, run via before_action ahead of every engine
|
|
68
|
+
# action (all controllers inherit from OpenehrRails::ApplicationController).
|
|
69
|
+
# A zero-arity proc, instance_exec'd in the controller -- so it can use
|
|
70
|
+
# request/render/redirect_to/head and any helper the host mixes into
|
|
71
|
+
# ActionController::Base (e.g. Devise's authenticate_user!). Deny by
|
|
72
|
+
# rendering or redirecting (standard before_action halting); some engine
|
|
73
|
+
# controllers rescue_from StandardError, so a hook that raises instead
|
|
74
|
+
# of rendering would be masked as a misleading error response --
|
|
75
|
+
# render/redirect only.
|
|
76
|
+
mattr_accessor :authenticate_with, default: nil
|
|
77
|
+
|
|
78
|
+
# Explicit escape hatch for intentionally-open deployments (e.g. behind
|
|
79
|
+
# a reverse proxy that already authenticates, or network-isolated
|
|
80
|
+
# internal-only apps). Overrides the environment-based default in
|
|
81
|
+
# EITHER direction: true forces access even in production; false forces
|
|
82
|
+
# denial even in development.
|
|
83
|
+
mattr_accessor :allow_unauthenticated_access, default: nil
|
|
84
|
+
|
|
85
|
+
def self.unauthenticated_access_allowed?
|
|
86
|
+
return allow_unauthenticated_access unless allow_unauthenticated_access.nil?
|
|
87
|
+
return false unless defined?(::Rails.env)
|
|
88
|
+
|
|
89
|
+
::Rails.env.development? || ::Rails.env.test?
|
|
90
|
+
end
|
|
66
91
|
end
|
|
@@ -40,6 +40,8 @@ describe Openehr::Generators::InstallGenerator do
|
|
|
40
40
|
.to contain("require 'openehr_rails'")
|
|
41
41
|
expect(file('config/initializers/openehr.rb'))
|
|
42
42
|
.to contain('rm_persistence_enabled')
|
|
43
|
+
expect(file('config/initializers/openehr.rb'))
|
|
44
|
+
.to contain('OpenehrRails.authenticate_with')
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
it 'creates the operational template directory' do
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'openehr_rails'
|
|
5
|
+
require_relative '../../app/controllers/openehr_rails/application_controller'
|
|
6
|
+
|
|
7
|
+
describe 'OpenehrRails engine authentication' do
|
|
8
|
+
after do
|
|
9
|
+
OpenehrRails.authenticate_with = nil
|
|
10
|
+
OpenehrRails.allow_unauthenticated_access = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe '.unauthenticated_access_allowed?' do
|
|
14
|
+
def stub_env(name)
|
|
15
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(name))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'is true in development when unconfigured' do
|
|
19
|
+
stub_env('development')
|
|
20
|
+
expect(OpenehrRails.unauthenticated_access_allowed?).to be true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'is true in test when unconfigured' do
|
|
24
|
+
stub_env('test')
|
|
25
|
+
expect(OpenehrRails.unauthenticated_access_allowed?).to be true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'is false in production when unconfigured' do
|
|
29
|
+
stub_env('production')
|
|
30
|
+
expect(OpenehrRails.unauthenticated_access_allowed?).to be false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'honours an explicit true override even in production' do
|
|
34
|
+
stub_env('production')
|
|
35
|
+
OpenehrRails.allow_unauthenticated_access = true
|
|
36
|
+
expect(OpenehrRails.unauthenticated_access_allowed?).to be true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'honours an explicit false override even in development' do
|
|
40
|
+
stub_env('development')
|
|
41
|
+
OpenehrRails.allow_unauthenticated_access = false
|
|
42
|
+
expect(OpenehrRails.unauthenticated_access_allowed?).to be false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe OpenehrRails::ApplicationController do
|
|
47
|
+
subject(:controller) { described_class.new }
|
|
48
|
+
|
|
49
|
+
def stub_request(json: true)
|
|
50
|
+
allow(controller).to receive(:request).and_return(
|
|
51
|
+
instance_double(ActionDispatch::Request, format: double('format', json?: json))
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
before do
|
|
56
|
+
allow(controller).to receive(:render)
|
|
57
|
+
stub_request
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'defaults openehr_access_scope to :admin' do
|
|
61
|
+
expect(controller.openehr_access_scope).to eq(:admin)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context 'when no hook is configured' do
|
|
65
|
+
it 'allows the request in development/test (unauthenticated_access_allowed? true)' do
|
|
66
|
+
allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(true)
|
|
67
|
+
|
|
68
|
+
controller.send(:authenticate_openehr_access!)
|
|
69
|
+
|
|
70
|
+
expect(controller).not_to have_received(:render)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'denies the request with 403 JSON outside development/test' do
|
|
74
|
+
allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(false)
|
|
75
|
+
stub_request(json: true)
|
|
76
|
+
|
|
77
|
+
controller.send(:authenticate_openehr_access!)
|
|
78
|
+
|
|
79
|
+
expect(controller).to have_received(:render).with(hash_including(status: :forbidden, json: anything))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'denies non-JSON requests with a plain-text 403' do
|
|
83
|
+
allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(false)
|
|
84
|
+
stub_request(json: false)
|
|
85
|
+
|
|
86
|
+
controller.send(:authenticate_openehr_access!)
|
|
87
|
+
|
|
88
|
+
expect(controller).to have_received(:render).with(hash_including(status: :forbidden, plain: anything))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context 'when a hook is configured' do
|
|
93
|
+
it 'defers entirely to the hook, even outside development/test' do
|
|
94
|
+
allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(false)
|
|
95
|
+
ran = false
|
|
96
|
+
OpenehrRails.authenticate_with = -> { ran = true }
|
|
97
|
+
|
|
98
|
+
controller.send(:authenticate_openehr_access!)
|
|
99
|
+
|
|
100
|
+
expect(ran).to be true
|
|
101
|
+
expect(controller).not_to have_received(:render)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'instance_execs the hook in controller context so it can call controller methods' do
|
|
105
|
+
def controller.custom_auth_check
|
|
106
|
+
render plain: 'nope', status: :unauthorized
|
|
107
|
+
end
|
|
108
|
+
OpenehrRails.authenticate_with = -> { custom_auth_check }
|
|
109
|
+
|
|
110
|
+
controller.send(:authenticate_openehr_access!)
|
|
111
|
+
|
|
112
|
+
expect(controller).to have_received(:render).with(hash_including(status: :unauthorized))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe 'openehr_access_scope overrides' do
|
|
118
|
+
require_relative '../../app/controllers/openehr_rails/fhir_controller'
|
|
119
|
+
require_relative '../../app/controllers/openehr_rails/queries_controller'
|
|
120
|
+
require_relative '../../app/controllers/openehr_rails/openehr_api/ehrs_controller'
|
|
121
|
+
require_relative '../../app/controllers/openehr_rails/openehr_api/compositions_controller'
|
|
122
|
+
|
|
123
|
+
it 'is :fhir for FhirController' do
|
|
124
|
+
expect(OpenehrRails::FhirController.new.openehr_access_scope).to eq(:fhir)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'is :rest_api for OpenehrApi::EhrsController and OpenehrApi::CompositionsController' do
|
|
128
|
+
expect(OpenehrRails::OpenehrApi::EhrsController.new.openehr_access_scope).to eq(:rest_api)
|
|
129
|
+
expect(OpenehrRails::OpenehrApi::CompositionsController.new.openehr_access_scope).to eq(:rest_api)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'is :rest_api for QueriesController#execute and :admin for QueriesController#show' do
|
|
133
|
+
controller = OpenehrRails::QueriesController.new
|
|
134
|
+
allow(controller).to receive(:action_name).and_return('execute')
|
|
135
|
+
expect(controller.openehr_access_scope).to eq(:rest_api)
|
|
136
|
+
|
|
137
|
+
allow(controller).to receive(:action_name).and_return('show')
|
|
138
|
+
expect(controller.openehr_access_scope).to eq(:admin)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -68,4 +68,39 @@ describe OpenehrRails::Opt::RemoteFetcher do
|
|
|
68
68
|
expect { described_class.fetch('https://example.com/slow.opt') }
|
|
69
69
|
.to raise_error(described_class::FetchError)
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
describe 'SSRF protection' do
|
|
73
|
+
# No stub_request for any of these: a correctly-blocked URL must never
|
|
74
|
+
# reach Net::HTTP at all (WebMock would raise its own "real requests
|
|
75
|
+
# are not allowed" error if it did, which -- being a different error
|
|
76
|
+
# class -- would also fail these expectations).
|
|
77
|
+
[
|
|
78
|
+
'127.0.0.1', # loopback
|
|
79
|
+
'169.254.169.254', # link-local, incl. cloud metadata endpoints
|
|
80
|
+
'10.1.2.3', # private
|
|
81
|
+
'172.16.0.5', # private
|
|
82
|
+
'192.168.1.1', # private
|
|
83
|
+
'[::1]' # IPv6 loopback
|
|
84
|
+
].each do |host|
|
|
85
|
+
it "rejects a URL targeting the blocked address #{host}" do
|
|
86
|
+
expect { described_class.fetch("http://#{host}/x.opt") }
|
|
87
|
+
.to raise_error(described_class::FetchError, /internal|private|blocked/i)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'rejects a redirect to a blocked address, not just the initial URL' do
|
|
92
|
+
stub_request(:get, 'https://example.com/external.opt')
|
|
93
|
+
.to_return(status: 302, headers: { 'Location' => 'http://127.0.0.1/internal.opt' })
|
|
94
|
+
|
|
95
|
+
expect { described_class.fetch('https://example.com/external.opt') }
|
|
96
|
+
.to raise_error(described_class::FetchError, /internal|private|blocked/i)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'still allows a normal public host' do
|
|
100
|
+
stub_request(:get, 'https://example.com/bmi_calculation.opt')
|
|
101
|
+
.to_return(status: 200, body: opt_body)
|
|
102
|
+
|
|
103
|
+
expect(described_class.fetch('https://example.com/bmi_calculation.opt')).to eq(opt_body)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
71
106
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openehr-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shinji KOBAYASHI
|
|
@@ -310,6 +310,7 @@ files:
|
|
|
310
310
|
- spec/openehr_rails/aql/executor_spec.rb
|
|
311
311
|
- spec/openehr_rails/aql/model_api_spec.rb
|
|
312
312
|
- spec/openehr_rails/aql/query_validator_spec.rb
|
|
313
|
+
- spec/openehr_rails/authentication_spec.rb
|
|
313
314
|
- spec/openehr_rails/fhir/profile_generator_spec.rb
|
|
314
315
|
- spec/openehr_rails/fhir/serializer_spec.rb
|
|
315
316
|
- spec/openehr_rails/naming_spec.rb
|