capsium 0.2.0 → 0.3.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/.rubocop.yml +12 -0
- data/CHANGELOG.md +125 -0
- data/README.adoc +332 -5
- data/capsium.gemspec +1 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +68 -6
- data/lib/capsium/cli/reactor.rb +9 -1
- data/lib/capsium/cli.rb +1 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/cipher.rb +197 -0
- data/lib/capsium/package/composition.rb +77 -0
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +109 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +14 -3
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +59 -75
- data/lib/capsium/packager.rb +34 -1
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +4 -1
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/serving.rb +75 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +43 -41
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/cipher.rbs +51 -0
- data/sig/capsium/package/composition.rbs +20 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +37 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +25 -17
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/serving.rbs +22 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +6 -1
- metadata +70 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# OAuth2 authorization-code flow client (05x-authentication), used by
|
|
11
|
+
# the Authenticator. Talks to the provider over net/http; the client
|
|
12
|
+
# secret never comes from the package. State is a random nonce signed
|
|
13
|
+
# with the session HMAC key (CSRF protection, self-contained).
|
|
14
|
+
class OAuth2
|
|
15
|
+
# The token exchange or userinfo fetch failed at the provider.
|
|
16
|
+
class FlowError < Capsium::Error; end
|
|
17
|
+
|
|
18
|
+
GRANT_TYPE = "authorization_code"
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
def initialize(config, client_secret:, session:, base_url: nil)
|
|
23
|
+
@config = config
|
|
24
|
+
@client_secret = client_secret
|
|
25
|
+
@session = session
|
|
26
|
+
@base_url = base_url
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The provider URL the login endpoint redirects the browser to.
|
|
30
|
+
def authorization_url(request)
|
|
31
|
+
params = {
|
|
32
|
+
"response_type" => "code",
|
|
33
|
+
"client_id" => @config.client_id,
|
|
34
|
+
"redirect_uri" => callback_uri(request),
|
|
35
|
+
"state" => generate_state
|
|
36
|
+
}
|
|
37
|
+
params["scope"] = @config.scopes.join(" ") unless @config.scopes.empty?
|
|
38
|
+
"#{@config.authorization_url}?#{URI.encode_www_form(params)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def valid_state?(state)
|
|
42
|
+
nonce, signature = state.to_s.split(".", 2)
|
|
43
|
+
return false if nonce.nil? || nonce.empty? || signature.nil?
|
|
44
|
+
|
|
45
|
+
Reactor.secure_compare(@session.sign(nonce), signature)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Exchanges the authorization code and fetches the userinfo claims.
|
|
49
|
+
# Returns the raw userinfo hash; raises FlowError on provider
|
|
50
|
+
# errors.
|
|
51
|
+
def complete(code, request)
|
|
52
|
+
userinfo(exchange_code(code, callback_uri(request)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def callback_uri(request)
|
|
56
|
+
"#{base_url_for(request)}#{@config.redirect_path}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def generate_state
|
|
62
|
+
nonce = SecureRandom.hex(16)
|
|
63
|
+
"#{nonce}.#{@session.sign(nonce)}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def base_url_for(request)
|
|
67
|
+
@base_url || "http://#{request['Host']}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def exchange_code(code, redirect_uri)
|
|
71
|
+
response = Net::HTTP.post_form(
|
|
72
|
+
URI(@config.token_url),
|
|
73
|
+
"grant_type" => GRANT_TYPE, "code" => code,
|
|
74
|
+
"redirect_uri" => redirect_uri, "client_id" => @config.client_id,
|
|
75
|
+
"client_secret" => @client_secret
|
|
76
|
+
)
|
|
77
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
78
|
+
raise FlowError, "token exchange failed (HTTP #{response.code})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
JSON.parse(response.body).fetch("access_token") do
|
|
82
|
+
raise FlowError, "token response without access_token"
|
|
83
|
+
end
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
raise FlowError, "token response was not JSON"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def userinfo(access_token)
|
|
89
|
+
uri = URI(@config.userinfo_url)
|
|
90
|
+
response = Net::HTTP.start(uri.host, uri.port,
|
|
91
|
+
use_ssl: uri.scheme == "https") do |http|
|
|
92
|
+
http.get(uri.request_uri,
|
|
93
|
+
"authorization" => "Bearer #{access_token}",
|
|
94
|
+
"accept" => "application/json")
|
|
95
|
+
end
|
|
96
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
97
|
+
raise FlowError, "userinfo request failed (HTTP #{response.code})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
JSON.parse(response.body)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise FlowError, "userinfo response was not JSON"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "marcel"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Reactor
|
|
8
|
+
# Serving of resolved routes (static files through the merged view,
|
|
9
|
+
# datasets, handler-route 501s), mixed into Reactor. Includes route
|
|
10
|
+
# inheritance processing per 05x-routing.
|
|
11
|
+
module Serving
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def serve_route(route, response)
|
|
15
|
+
case route.kind
|
|
16
|
+
when :dataset then serve_dataset(route.dataset, response)
|
|
17
|
+
when :resource then serve_file(route, response)
|
|
18
|
+
else respond_not_implemented(response)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def serve_file(route, response)
|
|
23
|
+
content_path = @merged_view.resolve(route.resource)
|
|
24
|
+
return respond_not_found(response) unless content_path
|
|
25
|
+
|
|
26
|
+
body, headers = inherited_processing(route, File.read(content_path),
|
|
27
|
+
headers_for(route))
|
|
28
|
+
response.status = 200
|
|
29
|
+
response["Content-Type"] = content_type_for(route, content_path)
|
|
30
|
+
headers.each { |name, value| response[name] = value }
|
|
31
|
+
response.body = body
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Route inheritance processing (05x-routing section "Route
|
|
35
|
+
# Inheritance"): responseRewrite replaces the body and/or overrides
|
|
36
|
+
# headers; responseHeaders are merged over the served headers.
|
|
37
|
+
# requestHeaders are parsed and exposed on the route for forwarding
|
|
38
|
+
# reactors; this reactor serves statically without an upstream, so
|
|
39
|
+
# they do not alter its responses.
|
|
40
|
+
def inherited_processing(route, body, headers)
|
|
41
|
+
rewrite = route.response_rewrite
|
|
42
|
+
if rewrite
|
|
43
|
+
body = rewrite.body unless rewrite.body.nil?
|
|
44
|
+
headers = headers.merge(rewrite.headers) if rewrite.headers
|
|
45
|
+
end
|
|
46
|
+
headers = headers.merge(route.response_headers) if route.response_headers
|
|
47
|
+
[body, headers]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The route's declared MIME type, detected from the resolved file
|
|
51
|
+
# for resources the manifest does not list (dependency content
|
|
52
|
+
# reached through inheritance, layer-only files).
|
|
53
|
+
def content_type_for(route, content_path)
|
|
54
|
+
route.mime(@package.manifest) ||
|
|
55
|
+
Marcel::MimeType.for(Pathname.new(content_path),
|
|
56
|
+
name: File.basename(content_path))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def headers_for(route)
|
|
60
|
+
route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serve_dataset(dataset_name, response)
|
|
64
|
+
dataset = @package.storage.dataset(dataset_name)
|
|
65
|
+
if dataset
|
|
66
|
+
response.status = 200
|
|
67
|
+
response["Content-Type"] = "application/json"
|
|
68
|
+
response.body = JSON.generate(dataset.data)
|
|
69
|
+
else
|
|
70
|
+
respond_not_found(response)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# HMAC-SHA256 signed session cookies (05x-authentication: "Session
|
|
11
|
+
# via signed cookie"). The cookie value is
|
|
12
|
+
# "base64url(JSON payload).hmac hex" — signed, not encrypted, so it
|
|
13
|
+
# carries only the identity claims. The signing secret comes from
|
|
14
|
+
# deploy.json (sessionSecret) or is generated and persisted
|
|
15
|
+
# reactor-side (never in the package).
|
|
16
|
+
class Session
|
|
17
|
+
COOKIE_NAME = "capsium_session"
|
|
18
|
+
SECRET_BYTES = 32
|
|
19
|
+
|
|
20
|
+
def self.hmac(secret, data)
|
|
21
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, data)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :secret
|
|
25
|
+
|
|
26
|
+
def initialize(secret: nil, state_file: nil)
|
|
27
|
+
@secret = secret || self.class.load_or_generate_secret(state_file)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# HMAC-SHA256 of data with the session secret (hex).
|
|
31
|
+
def sign(data)
|
|
32
|
+
self.class.hmac(@secret, data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The persisted reactor-side secret: loaded from state_file when
|
|
36
|
+
# present, generated and written (mode 0600) otherwise.
|
|
37
|
+
def self.load_or_generate_secret(state_file)
|
|
38
|
+
raise Error, "session secret or state_file is required" unless state_file
|
|
39
|
+
|
|
40
|
+
return File.read(state_file).strip if File.file?(state_file)
|
|
41
|
+
|
|
42
|
+
secret = SecureRandom.hex(SECRET_BYTES)
|
|
43
|
+
File.write(state_file, secret, perm: 0o600)
|
|
44
|
+
secret
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def encode(payload)
|
|
48
|
+
data = Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
|
|
49
|
+
"#{data}.#{self.class.hmac(@secret, data)}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The verified payload, or nil when the cookie is missing or the
|
|
53
|
+
# signature does not match.
|
|
54
|
+
def decode(cookie_value)
|
|
55
|
+
data, signature = cookie_value.to_s.split(".", 2)
|
|
56
|
+
return if data.nil? || signature.nil?
|
|
57
|
+
|
|
58
|
+
expected = self.class.hmac(@secret, data)
|
|
59
|
+
return unless Reactor.secure_compare(expected, signature)
|
|
60
|
+
|
|
61
|
+
JSON.parse(Base64.urlsafe_decode64(data))
|
|
62
|
+
rescue JSON::ParserError, ArgumentError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The identity for a request's Cookie header, or nil.
|
|
67
|
+
def identity_from(request)
|
|
68
|
+
cookie = request["Cookie"].to_s.split(/;\s*/).find do |part|
|
|
69
|
+
part.start_with?("#{COOKIE_NAME}=")
|
|
70
|
+
end
|
|
71
|
+
return unless cookie
|
|
72
|
+
|
|
73
|
+
decode(cookie.delete_prefix("#{COOKIE_NAME}="))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A Set-Cookie value establishing the identity session.
|
|
77
|
+
def cookie_for(identity)
|
|
78
|
+
"#{COOKIE_NAME}=#{encode(identity)}; Path=/; HttpOnly; SameSite=Lax"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/capsium/reactor.rb
CHANGED
|
@@ -2,24 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "listen"
|
|
5
|
+
require "tmpdir"
|
|
5
6
|
require "webrick"
|
|
6
7
|
|
|
7
8
|
module Capsium
|
|
8
9
|
class Reactor
|
|
10
|
+
autoload :Authenticator, "capsium/reactor/authenticator"
|
|
11
|
+
autoload :Deploy, "capsium/reactor/deploy"
|
|
12
|
+
autoload :Htpasswd, "capsium/reactor/htpasswd"
|
|
9
13
|
autoload :Introspection, "capsium/reactor/introspection"
|
|
14
|
+
autoload :OAuth2, "capsium/reactor/oauth2"
|
|
15
|
+
autoload :Serving, "capsium/reactor/serving"
|
|
16
|
+
autoload :Session, "capsium/reactor/session"
|
|
17
|
+
|
|
18
|
+
include Serving
|
|
10
19
|
|
|
11
20
|
DEFAULT_PORT = 8864
|
|
12
21
|
DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
|
|
13
22
|
|
|
14
23
|
attr_reader :package, :package_path, :routes, :port, :cache_control,
|
|
15
|
-
:server, :server_thread, :introspection
|
|
24
|
+
:server, :server_thread, :introspection, :authenticator
|
|
16
25
|
|
|
17
26
|
def initialize(package:, port: DEFAULT_PORT,
|
|
18
|
-
cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false
|
|
19
|
-
|
|
27
|
+
cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
|
|
28
|
+
store: nil, deploy: nil)
|
|
29
|
+
@package = package.is_a?(String) ? Package.new(package, store: store) : package
|
|
20
30
|
@package_path = @package.path
|
|
31
|
+
@store = store
|
|
21
32
|
@port = port
|
|
22
33
|
@cache_control = cache_control
|
|
34
|
+
@deploy_config = Deploy.load(deploy)
|
|
23
35
|
setup_server(do_not_listen)
|
|
24
36
|
load_state
|
|
25
37
|
mount_routes
|
|
@@ -32,14 +44,29 @@ module Capsium
|
|
|
32
44
|
end
|
|
33
45
|
|
|
34
46
|
def handle_request(request, response)
|
|
47
|
+
if @authenticator.endpoint?(request.path)
|
|
48
|
+
@authenticator.serve_endpoint(request, response)
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
identity = @authenticator.authenticate(request)
|
|
53
|
+
return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
|
|
35
54
|
return serve_introspection(request, response) if @introspection.endpoint?(request.path)
|
|
36
55
|
|
|
37
56
|
route = @routes.resolve(request.path)
|
|
38
|
-
|
|
57
|
+
return respond_not_found(response) unless route
|
|
58
|
+
|
|
59
|
+
case @authenticator.authorize(identity, route.access_control)
|
|
60
|
+
when :unauthenticated then return @authenticator.challenge(response)
|
|
61
|
+
when :forbidden then return respond_forbidden(response)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
serve_route(route, response)
|
|
39
65
|
end
|
|
40
66
|
|
|
41
67
|
def mount_routes
|
|
42
|
-
paths = @routes.config.routes.map(&:
|
|
68
|
+
paths = @routes.config.routes.map(&:serving_path) +
|
|
69
|
+
Introspection::PATHS + @authenticator.endpoints
|
|
43
70
|
paths.each do |path|
|
|
44
71
|
@server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
|
|
45
72
|
end
|
|
@@ -87,13 +114,21 @@ module Capsium
|
|
|
87
114
|
end
|
|
88
115
|
|
|
89
116
|
def load_package
|
|
90
|
-
@package = Package.new(@package_path)
|
|
117
|
+
@package = Package.new(@package_path, store: @store)
|
|
91
118
|
load_state
|
|
92
119
|
end
|
|
93
120
|
|
|
94
121
|
def load_state
|
|
95
122
|
@routes = @package.routes
|
|
123
|
+
@merged_view = @package.merged_view
|
|
96
124
|
@introspection = Introspection.new(@package)
|
|
125
|
+
@authenticator = Authenticator.new(
|
|
126
|
+
@package.authentication,
|
|
127
|
+
deploy: @deploy_config,
|
|
128
|
+
package_path: @package.path,
|
|
129
|
+
base_url: @deploy_config.base_url,
|
|
130
|
+
state_file: File.join(Dir.tmpdir, "capsium-#{@package.name}-session-secret")
|
|
131
|
+
)
|
|
97
132
|
end
|
|
98
133
|
|
|
99
134
|
def serve_introspection(request, response)
|
|
@@ -104,43 +139,10 @@ module Capsium
|
|
|
104
139
|
response.body = JSON.generate(@introspection.report_for(request.path))
|
|
105
140
|
end
|
|
106
141
|
|
|
107
|
-
def serve_route(route, response)
|
|
108
|
-
case route.kind
|
|
109
|
-
when :dataset then serve_dataset(route.dataset, response)
|
|
110
|
-
when :resource then serve_file(route, response)
|
|
111
|
-
else respond_not_implemented(response)
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def serve_file(route, response)
|
|
116
|
-
content_path = route.fs_path(@package.path)
|
|
117
|
-
if content_path && File.exist?(content_path)
|
|
118
|
-
response.status = 200
|
|
119
|
-
response["Content-Type"] = route.mime(@package.manifest) || "application/octet-stream"
|
|
120
|
-
headers_for(route).each { |name, value| response[name] = value }
|
|
121
|
-
response.body = File.read(content_path)
|
|
122
|
-
else
|
|
123
|
-
respond_not_found(response)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def headers_for(route)
|
|
128
|
-
route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def serve_dataset(dataset_name, response)
|
|
132
|
-
dataset = @package.storage.dataset(dataset_name)
|
|
133
|
-
if dataset
|
|
134
|
-
response.status = 200
|
|
135
|
-
response["Content-Type"] = "application/json"
|
|
136
|
-
response.body = JSON.generate(dataset.data)
|
|
137
|
-
else
|
|
138
|
-
respond_not_found(response)
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
142
|
def respond_not_found(response) = respond_text(response, 404, "Not Found")
|
|
143
143
|
|
|
144
|
+
def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
|
|
145
|
+
|
|
144
146
|
def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
|
|
145
147
|
|
|
146
148
|
def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
|
data/lib/capsium/thor_ext.rb
CHANGED
|
@@ -60,7 +60,7 @@ module Capsium
|
|
|
60
60
|
return if error.is_a?(Errno::EPIPE)
|
|
61
61
|
raise if ENV["VERBOSE"] || !config.fetch(:exit_on_failure, true)
|
|
62
62
|
|
|
63
|
-
message = error.message.to_s
|
|
63
|
+
message = +error.message.to_s
|
|
64
64
|
message.prepend("[#{error.class}] ") if message.empty? || !error.is_a?(Thor::Error)
|
|
65
65
|
|
|
66
66
|
config[:shell]&.say_error(message, :red)
|
data/lib/capsium/version.rb
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
class Package
|
|
3
|
+
# Loads a package's authentication.json (ARCHITECTURE.md section 4b).
|
|
4
|
+
class Authentication
|
|
5
|
+
attr_reader path: String
|
|
6
|
+
attr_reader config: AuthenticationConfig?
|
|
7
|
+
|
|
8
|
+
def initialize: (String path) -> void
|
|
9
|
+
|
|
10
|
+
def present?: () -> bool
|
|
11
|
+
|
|
12
|
+
def basic_auth: () -> BasicAuthConfig?
|
|
13
|
+
|
|
14
|
+
def oauth2: () -> OAuth2Config?
|
|
15
|
+
|
|
16
|
+
# Whether any authentication method is enabled.
|
|
17
|
+
def enabled?: () -> bool
|
|
18
|
+
|
|
19
|
+
def to_json: (*untyped args) -> String
|
|
20
|
+
def to_hash: () -> Hash[String, untyped]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
class Package
|
|
3
|
+
# The "basicAuth" object of authentication.json (ARCHITECTURE.md
|
|
4
|
+
# section 4b): Apache-style Basic authentication verified against an
|
|
5
|
+
# htpasswd file.
|
|
6
|
+
class BasicAuthConfig
|
|
7
|
+
attr_accessor enabled: bool
|
|
8
|
+
attr_accessor passwd_file: String?
|
|
9
|
+
attr_accessor realm: String
|
|
10
|
+
|
|
11
|
+
def initialize: (?enabled: bool enabled, ?passwd_file: String? passwd_file,
|
|
12
|
+
?realm: String? realm) -> void
|
|
13
|
+
|
|
14
|
+
def enabled?: () -> bool
|
|
15
|
+
|
|
16
|
+
def to_json: (*untyped args) -> String
|
|
17
|
+
def to_hash: () -> Hash[String, untyped]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The "oauth2" object of authentication.json: an OAuth2
|
|
21
|
+
# authorization-code flow. The client secret is NEVER read from the
|
|
22
|
+
# package — it comes from deploy.json or reactor configuration.
|
|
23
|
+
class OAuth2Config
|
|
24
|
+
attr_accessor enabled: bool
|
|
25
|
+
attr_accessor provider: String?
|
|
26
|
+
attr_accessor client_id: String?
|
|
27
|
+
attr_accessor authorization_url: String?
|
|
28
|
+
attr_accessor token_url: String?
|
|
29
|
+
attr_accessor userinfo_url: String?
|
|
30
|
+
attr_accessor redirect_path: String
|
|
31
|
+
attr_accessor scopes: Array[String]
|
|
32
|
+
|
|
33
|
+
def initialize: (?enabled: bool enabled, ?provider: String? provider,
|
|
34
|
+
?client_id: String? client_id,
|
|
35
|
+
?authorization_url: String? authorization_url,
|
|
36
|
+
?token_url: String? token_url,
|
|
37
|
+
?userinfo_url: String? userinfo_url,
|
|
38
|
+
?redirect_path: String? redirect_path,
|
|
39
|
+
?scopes: Array[String] scopes) -> void
|
|
40
|
+
|
|
41
|
+
def enabled?: () -> bool
|
|
42
|
+
|
|
43
|
+
def to_json: (*untyped args) -> String
|
|
44
|
+
def to_hash: () -> Hash[String, untyped]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The "authentication" object of authentication.json.
|
|
48
|
+
class AuthenticationData
|
|
49
|
+
attr_accessor basic_auth: BasicAuthConfig?
|
|
50
|
+
attr_accessor oauth2: OAuth2Config?
|
|
51
|
+
|
|
52
|
+
def initialize: (?basic_auth: BasicAuthConfig? basic_auth,
|
|
53
|
+
?oauth2: OAuth2Config? oauth2) -> void
|
|
54
|
+
|
|
55
|
+
def to_json: (*untyped args) -> String
|
|
56
|
+
def to_hash: () -> Hash[String, untyped]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Canonical authentication.json model (ARCHITECTURE.md section 4b).
|
|
60
|
+
class AuthenticationConfig
|
|
61
|
+
attr_accessor authentication: AuthenticationData?
|
|
62
|
+
|
|
63
|
+
def initialize: (?authentication: AuthenticationData? authentication) -> void
|
|
64
|
+
|
|
65
|
+
def self.from_json: (String json) -> instance
|
|
66
|
+
|
|
67
|
+
def to_json: (*untyped args) -> String
|
|
68
|
+
def to_hash: () -> Hash[String, untyped]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
class Package
|
|
3
|
+
# Encrypts and decrypts whole Capsium packages (05x-packaging
|
|
4
|
+
# "Encryption", 05x-security "Encrypted information"). An encrypted
|
|
5
|
+
# .cap is a zip containing metadata.json (cleartext), signature.json
|
|
6
|
+
# (the encryption envelope) and package.enc (AES-256-GCM of the inner
|
|
7
|
+
# plaintext .cap zip). The random DEK is wrapped with the recipient's
|
|
8
|
+
# RSA public key using OAEP with SHA-256. OCB/OpenPGP are out of
|
|
9
|
+
# scope.
|
|
10
|
+
class Cipher
|
|
11
|
+
ALGORITHM: String
|
|
12
|
+
KEY_MANAGEMENT: String
|
|
13
|
+
CIPHER_NAME: String
|
|
14
|
+
ENCRYPTED_FILE: String
|
|
15
|
+
ENVELOPE_FILE: String
|
|
16
|
+
RSA_OPTIONS: Hash[String, String]
|
|
17
|
+
|
|
18
|
+
# Structural problems: unreadable input, missing zip entries,
|
|
19
|
+
# unsupported envelope algorithms, unloadable keys.
|
|
20
|
+
class CipherError < Capsium::Error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An encrypted package was opened without a private key.
|
|
24
|
+
class KeyRequiredError < CipherError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Decryption failed: wrong private key or tampered ciphertext.
|
|
28
|
+
class DecryptionError < CipherError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether the path (.cap file or uncompressed directory) is an
|
|
32
|
+
# encrypted package, i.e. contains package.enc.
|
|
33
|
+
def self.encrypted?: (String | Pathname path) -> bool
|
|
34
|
+
|
|
35
|
+
# Encrypts the package at source_path (a .cap file, or a package
|
|
36
|
+
# directory which is packed first) for the recipient's RSA public
|
|
37
|
+
# key (or X.509 certificate) and writes the encrypted .cap to
|
|
38
|
+
# output_path. Returns output_path.
|
|
39
|
+
def encrypt: (String source_path, String public_key_path, String output_path) -> String
|
|
40
|
+
|
|
41
|
+
# Decrypts the encrypted package at encrypted_path (.cap file or
|
|
42
|
+
# uncompressed directory) with the recipient's RSA private key and
|
|
43
|
+
# writes the plaintext .cap to output_path. Returns output_path.
|
|
44
|
+
def decrypt: (String | Pathname encrypted_path, String private_key_path, String output_path) -> String
|
|
45
|
+
|
|
46
|
+
# Decrypts an encrypted package into a fresh temporary directory
|
|
47
|
+
# and returns the directory path.
|
|
48
|
+
def self.decrypt_to_directory: (String | Pathname source_path, String private_key_path) -> String
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
class Package
|
|
3
|
+
# Composite-package support (ARCHITECTURE.md section 4a), mixed into
|
|
4
|
+
# Package: metadata.dependencies resolution against a package store
|
|
5
|
+
# and load-time validation of dependency resource references.
|
|
6
|
+
module Composition
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def resolve_dependencies: ((Store | String)? store, Array[String] chain) -> Array[ResolvedDependency]
|
|
10
|
+
|
|
11
|
+
def validate_dependency_references!: () -> void
|
|
12
|
+
|
|
13
|
+
def validate_dependency_reference: (Route route, ResolvedDependency dependency, String reference) -> void
|
|
14
|
+
|
|
15
|
+
def dependency_for: (String reference) -> ResolvedDependency?
|
|
16
|
+
|
|
17
|
+
def dependency_views: () -> Array[[String, MergedView]]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
class Package
|
|
3
|
+
# Base error for composite-package dependency resolution
|
|
4
|
+
# (ARCHITECTURE.md section 4a).
|
|
5
|
+
class DependencyError < Capsium::Error
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# No package in the store provides the dependency GUID.
|
|
9
|
+
class DependencyNotFoundError < DependencyError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The store provides the GUID but no version satisfies the range.
|
|
13
|
+
class UnsatisfiableDependencyError < DependencyError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A dependency cycle was detected while resolving.
|
|
17
|
+
class CircularDependencyError < DependencyError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A dependent route references a dependency resource that is not
|
|
21
|
+
# exported (private) and therefore not visible to dependents.
|
|
22
|
+
class DependencyVisibilityError < DependencyError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One resolved dependency: the declared GUID and range, the .cap it
|
|
26
|
+
# resolved to and the loaded package.
|
|
27
|
+
class ResolvedDependency
|
|
28
|
+
def self.new: (String guid, String range, String path, Package package) -> void
|
|
29
|
+
|
|
30
|
+
def guid: () -> String
|
|
31
|
+
def range: () -> String
|
|
32
|
+
def path: () -> String
|
|
33
|
+
def package: () -> Package
|
|
34
|
+
def version: () -> String?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Orchestrates metadata.dependencies resolution against a package
|
|
38
|
+
# store: circularity checks plus store lookup.
|
|
39
|
+
class DependencyResolver
|
|
40
|
+
attr_reader store: Store
|
|
41
|
+
|
|
42
|
+
def initialize: (Store | String? store) -> void
|
|
43
|
+
|
|
44
|
+
# The newest store .cap satisfying the dependency, raising typed
|
|
45
|
+
# errors for circular, missing or unsatisfiable dependencies.
|
|
46
|
+
def resolve_path: (String guid, String range, chain: Array[String] chain) -> String
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
class Package
|
|
3
|
+
# The "encryption" object of signature.json in an encrypted package:
|
|
4
|
+
# the AES-256-GCM envelope with the RSA-OAEP-SHA256 wrapped DEK, all
|
|
5
|
+
# binary values Base64-encoded.
|
|
6
|
+
class EncryptionEnvelope
|
|
7
|
+
attr_accessor algorithm: String?
|
|
8
|
+
attr_accessor key_management: String?
|
|
9
|
+
attr_accessor encrypted_dek: String?
|
|
10
|
+
attr_accessor iv: String?
|
|
11
|
+
attr_accessor auth_tag: String?
|
|
12
|
+
|
|
13
|
+
def initialize: (?algorithm: String? algorithm,
|
|
14
|
+
?key_management: String? key_management,
|
|
15
|
+
?encrypted_dek: String? encrypted_dek,
|
|
16
|
+
?iv: String? iv,
|
|
17
|
+
?auth_tag: String? auth_tag) -> void
|
|
18
|
+
|
|
19
|
+
def to_json: (*untyped args) -> String
|
|
20
|
+
def to_hash: () -> Hash[String, untyped]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The signature.json file of an encrypted package.
|
|
24
|
+
class EncryptionConfig
|
|
25
|
+
attr_accessor encryption: EncryptionEnvelope?
|
|
26
|
+
|
|
27
|
+
def initialize: (?encryption: EncryptionEnvelope? encryption) -> void
|
|
28
|
+
|
|
29
|
+
def self.from_json: (String json) -> instance
|
|
30
|
+
|
|
31
|
+
def to_json: (*untyped args) -> String
|
|
32
|
+
def to_hash: () -> Hash[String, untyped]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|