capsium 0.2.0 → 0.6.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 +17 -1
- data/CHANGELOG.md +322 -0
- data/README.adoc +643 -7
- data/capsium.gemspec +3 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +106 -7
- data/lib/capsium/cli/reactor.rb +120 -6
- data/lib/capsium/cli.rb +30 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +253 -0
- data/lib/capsium/package/composition.rb +93 -0
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +91 -0
- data/lib/capsium/package/encryption_config.rb +42 -0
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +170 -0
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -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/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +216 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +136 -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 +39 -3
- data/lib/capsium/package/verification.rb +34 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +78 -75
- data/lib/capsium/packager.rb +97 -22
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +151 -31
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +154 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +116 -67
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +62 -0
- data/sig/capsium/package/composition.rbs +21 -0
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +53 -0
- data/sig/capsium/package/encryption_config.rbs +39 -0
- data/sig/capsium/package/merged_view.rbs +47 -0
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.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/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +66 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +43 -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 +26 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +29 -16
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +46 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +22 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- metadata +135 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# REST CRUD over a mount's datasets (ARCHITECTURE.md sections 5 and
|
|
8
|
+
# 5a): JSON item append/read/replace/delete against the overlay-
|
|
9
|
+
# merged collection. Writes go only to the mount's writable overlay;
|
|
10
|
+
# the immutable package never changes on disk.
|
|
11
|
+
class DataApi
|
|
12
|
+
include Responses
|
|
13
|
+
|
|
14
|
+
PREFIX = "/api/v1/data/"
|
|
15
|
+
COLLECTION_PATTERN = %r{\A/api/v1/data/(?<dataset>[^/]+)\z}
|
|
16
|
+
ITEM_PATTERN = %r{\A/api/v1/data/(?<dataset>[^/]+)/(?<id>[^/]+)\z}
|
|
17
|
+
|
|
18
|
+
def self.path?(inner_path)
|
|
19
|
+
inner_path.start_with?(PREFIX)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(mount)
|
|
23
|
+
@mount = mount
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The declared dataset route a data path addresses (the collection
|
|
27
|
+
# route, also for item paths), or nil when the package does not
|
|
28
|
+
# route this dataset — undeclared datasets are not served.
|
|
29
|
+
def route_for(inner_path)
|
|
30
|
+
match = COLLECTION_PATTERN.match(inner_path) || ITEM_PATTERN.match(inner_path)
|
|
31
|
+
return nil unless match
|
|
32
|
+
|
|
33
|
+
route = @mount.routes.resolve("#{PREFIX}#{match[:dataset]}")
|
|
34
|
+
route if route&.dataset == match[:dataset]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle(inner_path, request, response)
|
|
38
|
+
route = route_for(inner_path)
|
|
39
|
+
return respond_not_found(response) unless route
|
|
40
|
+
|
|
41
|
+
dataset = @mount.package.storage.dataset(route.dataset)
|
|
42
|
+
return respond_not_found(response) unless dataset
|
|
43
|
+
|
|
44
|
+
collection = COLLECTION_PATTERN.match(inner_path)
|
|
45
|
+
if collection
|
|
46
|
+
handle_collection(dataset, request, response)
|
|
47
|
+
else
|
|
48
|
+
handle_item(dataset, ITEM_PATTERN.match(inner_path)[:id], request, response)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def handle_collection(dataset, request, response)
|
|
55
|
+
case request.request_method
|
|
56
|
+
when "GET" then respond_json(response, 200, @mount.dataset_data(dataset))
|
|
57
|
+
when "POST" then append_item(dataset, request, response)
|
|
58
|
+
else respond_method_not_allowed(response)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_item(dataset, id, request, response)
|
|
63
|
+
case request.request_method
|
|
64
|
+
when "GET" then read_item(dataset, id, response)
|
|
65
|
+
when "PUT" then replace_item(dataset, id, request, response)
|
|
66
|
+
when "DELETE" then delete_item(dataset, id, response)
|
|
67
|
+
else respond_method_not_allowed(response)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_item(dataset, request, response)
|
|
72
|
+
return unless writable?(dataset, response)
|
|
73
|
+
|
|
74
|
+
item = parse_body(request, response)
|
|
75
|
+
return if item.nil?
|
|
76
|
+
|
|
77
|
+
id = @mount.overlay.append_item(dataset, item)
|
|
78
|
+
response["Location"] = "#{request.path}/#{id}"
|
|
79
|
+
respond_json(response, 201, item)
|
|
80
|
+
rescue Overlay::Error => e
|
|
81
|
+
respond_overlay_error(response, e)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def read_item(dataset, id, response)
|
|
85
|
+
respond_json(response, 200, @mount.overlay.item(dataset, id))
|
|
86
|
+
rescue Overlay::ItemNotFoundError
|
|
87
|
+
respond_not_found(response)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replace_item(dataset, id, request, response)
|
|
91
|
+
return unless writable?(dataset, response)
|
|
92
|
+
|
|
93
|
+
item = parse_body(request, response)
|
|
94
|
+
return if item.nil?
|
|
95
|
+
|
|
96
|
+
@mount.overlay.replace_item(dataset, id, item)
|
|
97
|
+
respond_json(response, 200, item)
|
|
98
|
+
rescue Overlay::ItemNotFoundError
|
|
99
|
+
respond_not_found(response)
|
|
100
|
+
rescue Overlay::Error => e
|
|
101
|
+
respond_overlay_error(response, e)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delete_item(dataset, id, response)
|
|
105
|
+
return unless writable?(dataset, response)
|
|
106
|
+
|
|
107
|
+
@mount.overlay.delete_item(dataset, id)
|
|
108
|
+
response.status = 204
|
|
109
|
+
rescue Overlay::ItemNotFoundError
|
|
110
|
+
respond_not_found(response)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Write guards: 403 for a read-only mount, 501 for a SQLite
|
|
114
|
+
# dataset (not file-backed JSON/YAML).
|
|
115
|
+
def writable?(dataset, response)
|
|
116
|
+
unless @mount.writable?
|
|
117
|
+
respond_error(response, 403, "package #{@mount.package.name} is read-only")
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
if dataset.config.sqlite?
|
|
121
|
+
respond_error(response, 501,
|
|
122
|
+
"dataset #{dataset.name} is SQLite; writes are not supported")
|
|
123
|
+
return false
|
|
124
|
+
end
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The JSON request body, or nil after answering 400 (invalid JSON)
|
|
129
|
+
# or 422 (a null body cannot be an item).
|
|
130
|
+
def parse_body(request, response)
|
|
131
|
+
body = JSON.parse(request.body.to_s)
|
|
132
|
+
if body.nil?
|
|
133
|
+
respond_error(response, 422, "request body must not be null")
|
|
134
|
+
return nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
body
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
respond_error(response, 400, "invalid JSON body: #{e.message}")
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def respond_overlay_error(response, error)
|
|
144
|
+
case error
|
|
145
|
+
when Overlay::ConflictError then respond_error(response, 409, error.message)
|
|
146
|
+
when Overlay::SchemaViolationError
|
|
147
|
+
respond_error(response, 422, "schema validation failed",
|
|
148
|
+
messages: error.messages)
|
|
149
|
+
else respond_error(response, 422, error.message)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Reactor-side deployment configuration (deploy.json): everything
|
|
8
|
+
# that must NOT ship in the package — OAuth2 client secrets, the
|
|
9
|
+
# session signing secret, role assignments, an alternate htpasswd
|
|
10
|
+
# file, the public base URL. Shape:
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "baseUrl": "http://localhost:8864",
|
|
14
|
+
# "authentication": {
|
|
15
|
+
# "basicAuth": { "passwdFile": "/secure/.htpasswd" },
|
|
16
|
+
# "oauth2": { "clientSecret": "..." },
|
|
17
|
+
# "sessionSecret": "...",
|
|
18
|
+
# "roles": { "alice": ["admin"] }
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# Loaded from an explicit path, an already-parsed Hash, or the
|
|
23
|
+
# CAPSIUM_DEPLOY environment variable; empty when unconfigured.
|
|
24
|
+
class Deploy
|
|
25
|
+
FILE = "deploy.json"
|
|
26
|
+
ENV_VAR = "CAPSIUM_DEPLOY"
|
|
27
|
+
|
|
28
|
+
attr_reader :config
|
|
29
|
+
|
|
30
|
+
def self.load(source)
|
|
31
|
+
return new(source) if source.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
path = source || ENV.fetch(ENV_VAR, nil)
|
|
34
|
+
return new({}) if path.nil? || path.to_s.empty?
|
|
35
|
+
raise Error, "deploy configuration not found: #{path}" unless File.file?(path)
|
|
36
|
+
|
|
37
|
+
new(JSON.parse(File.read(path)))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(config)
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def authentication
|
|
45
|
+
config["authentication"] || {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def base_url
|
|
49
|
+
config["baseUrl"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def client_secret
|
|
53
|
+
authentication.dig("oauth2", "clientSecret")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def session_secret
|
|
57
|
+
authentication["sessionSecret"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def passwd_file
|
|
61
|
+
authentication.dig("basicAuth", "passwdFile")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Role assignments keyed by identity name (basic-auth username,
|
|
65
|
+
# OAuth2 email or subject): {"alice": ["admin", "user"]}.
|
|
66
|
+
def roles
|
|
67
|
+
authentication["roles"] || {}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Serving of the reactor-level endpoints (introspection reports,
|
|
8
|
+
# per-package save), mixed into Reactor.
|
|
9
|
+
module Endpoints
|
|
10
|
+
include Responses
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def serve_introspection(request, response)
|
|
15
|
+
return respond_method_not_allowed(response) unless request.request_method == "GET"
|
|
16
|
+
|
|
17
|
+
report = @introspection.report_for(request.path, params: request.query)
|
|
18
|
+
return respond_not_found(response) if report.nil?
|
|
19
|
+
|
|
20
|
+
respond_json(response, 200, report)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# POST /package/<name>/save: folds the mounted package's base plus
|
|
24
|
+
# its overlay into a new versioned .cap in the workdir.
|
|
25
|
+
def serve_package_save(request, response)
|
|
26
|
+
return respond_method_not_allowed(response) unless request.request_method == "POST"
|
|
27
|
+
|
|
28
|
+
name = PACKAGE_SAVE_PATTERN.match(request.path)[:name]
|
|
29
|
+
mount = @mounts.find { |candidate| candidate.package.name == name }
|
|
30
|
+
return respond_not_found(response) unless mount
|
|
31
|
+
return respond_error(response, 403, "package #{name} is read-only") unless mount.writable?
|
|
32
|
+
|
|
33
|
+
respond_json(response, 200, PackageSaver.new(mount).save(@workdir))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Reactor
|
|
8
|
+
# GraphQL over a mount's datasets (POST and GET <mount>/graphql):
|
|
9
|
+
# executes the schema GraphqlSchema derives from the package's
|
|
10
|
+
# storage and maps the overlay's typed errors into the "errors"
|
|
11
|
+
# array (never a 500 on user error). Query fields read the merged
|
|
12
|
+
# collection; mutations match the REST semantics, schema validation
|
|
13
|
+
# included.
|
|
14
|
+
class GraphqlApi
|
|
15
|
+
include Responses
|
|
16
|
+
|
|
17
|
+
PATH = "/graphql"
|
|
18
|
+
|
|
19
|
+
def self.path?(inner_path)
|
|
20
|
+
inner_path == PATH
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(mount)
|
|
24
|
+
@mount = mount
|
|
25
|
+
@schema = GraphqlSchema.new(mount, self).build
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def handle(request, response)
|
|
29
|
+
case request.request_method
|
|
30
|
+
when "POST" then handle_post(request, response)
|
|
31
|
+
when "GET" then handle_get(request, response)
|
|
32
|
+
else respond_method_not_allowed(response)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Query/mutation resolvers (public; called from the derived
|
|
37
|
+
# schema's fields).
|
|
38
|
+
def collection(dataset, id: nil)
|
|
39
|
+
return @mount.overlay.items(dataset) if id.nil?
|
|
40
|
+
|
|
41
|
+
[@mount.overlay.item(dataset, id)]
|
|
42
|
+
rescue Overlay::Error => e
|
|
43
|
+
raise execution_error(e)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_item(dataset, item:)
|
|
47
|
+
ensure_writable!
|
|
48
|
+
@mount.overlay.append_item(dataset, item)
|
|
49
|
+
item
|
|
50
|
+
rescue Overlay::Error => e
|
|
51
|
+
raise execution_error(e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update_item(dataset, id:, item:)
|
|
55
|
+
ensure_writable!
|
|
56
|
+
@mount.overlay.replace_item(dataset, id, item)
|
|
57
|
+
item
|
|
58
|
+
rescue Overlay::Error => e
|
|
59
|
+
raise execution_error(e)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove_item(dataset, id:)
|
|
63
|
+
ensure_writable!
|
|
64
|
+
@mount.overlay.delete_item(dataset, id)
|
|
65
|
+
true
|
|
66
|
+
rescue Overlay::Error => e
|
|
67
|
+
raise execution_error(e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def handle_post(request, response)
|
|
73
|
+
payload = JSON.parse(request.body.to_s)
|
|
74
|
+
unless payload.is_a?(Hash) && payload["query"].is_a?(String)
|
|
75
|
+
return respond_error(response, 400, "GraphQL POST body must be " \
|
|
76
|
+
'{"query": "...", "variables": {...}}')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
execute(response, payload["query"], variables: payload["variables"],
|
|
80
|
+
operation_name: payload["operationName"])
|
|
81
|
+
rescue JSON::ParserError => e
|
|
82
|
+
respond_error(response, 400, "invalid JSON body: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_get(request, response)
|
|
86
|
+
query = request.query["query"]
|
|
87
|
+
return respond_error(response, 400, "missing GraphQL query") if query.nil?
|
|
88
|
+
|
|
89
|
+
execute(response, query, variables: parse_variables(request.query["variables"]),
|
|
90
|
+
operation_name: request.query["operationName"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_variables(raw)
|
|
94
|
+
return nil if raw.nil?
|
|
95
|
+
|
|
96
|
+
JSON.parse(raw)
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def execute(response, query, variables:, operation_name:)
|
|
102
|
+
result = @schema.execute(query, variables: variables || {},
|
|
103
|
+
operation_name: operation_name)
|
|
104
|
+
respond_json(response, 200, result.to_h)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ensure_writable!
|
|
108
|
+
return if @mount.writable?
|
|
109
|
+
|
|
110
|
+
raise GraphQL::ExecutionError, "package #{@mount.package.name} is read-only"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def execution_error(error)
|
|
114
|
+
message = if error.is_a?(Overlay::SchemaViolationError)
|
|
115
|
+
error.messages.join("; ")
|
|
116
|
+
else
|
|
117
|
+
error.message
|
|
118
|
+
end
|
|
119
|
+
GraphQL::ExecutionError.new(message)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Builds the GraphQL schema for a mounted package (ARCHITECTURE.md
|
|
8
|
+
# section 5): per dataset a query field "<dataset>" (list, optional
|
|
9
|
+
# "id:" argument for a single item) plus
|
|
10
|
+
# create<Dataset>/update<Dataset>/delete<Dataset> mutations.
|
|
11
|
+
# Item types are inferred from the dataset's JSON schema when
|
|
12
|
+
# present, else map to a permissive JSON scalar. Field resolvers
|
|
13
|
+
# delegate to the given API object's public methods.
|
|
14
|
+
class GraphqlSchema
|
|
15
|
+
# Permissive scalar carrying any JSON value (schema-less items,
|
|
16
|
+
# mutation inputs, nested structures).
|
|
17
|
+
class JsonScalar < GraphQL::Schema::Scalar
|
|
18
|
+
graphql_name("JSON")
|
|
19
|
+
description "An arbitrary JSON value"
|
|
20
|
+
|
|
21
|
+
def self.coerce_input(input_value, _context) = input_value
|
|
22
|
+
|
|
23
|
+
def self.coerce_result(ruby_value, _context) = ruby_value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(mount, api)
|
|
27
|
+
@mount = mount
|
|
28
|
+
@api = api
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build
|
|
32
|
+
api = @api
|
|
33
|
+
query_type = Class.new(GraphQL::Schema::Object) { graphql_name("Query") }
|
|
34
|
+
mutation_type = Class.new(GraphQL::Schema::Object) { graphql_name("Mutation") }
|
|
35
|
+
@mount.package.storage.datasets.each do |dataset|
|
|
36
|
+
next if dataset.config.sqlite?
|
|
37
|
+
|
|
38
|
+
add_dataset_fields(query_type, mutation_type, dataset, api)
|
|
39
|
+
end
|
|
40
|
+
Class.new(GraphQL::Schema) do
|
|
41
|
+
query(query_type)
|
|
42
|
+
mutation(mutation_type)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def add_dataset_fields(query_type, mutation_type, dataset, api)
|
|
49
|
+
name = graphql_name_for(dataset.name)
|
|
50
|
+
type = item_type_for(dataset, name)
|
|
51
|
+
add_query_field(query_type, name, type, dataset, api)
|
|
52
|
+
add_mutation_fields(mutation_type, name, type, dataset, api)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "<dataset>" returns the merged item list; with "id:" the single
|
|
56
|
+
# matching item (a one-element list, not-found in "errors").
|
|
57
|
+
def add_query_field(query_type, name, type, dataset, api)
|
|
58
|
+
query_type.class_eval do
|
|
59
|
+
field name, [type], null: false, camelize: false do
|
|
60
|
+
argument :id, String, required: false
|
|
61
|
+
end
|
|
62
|
+
define_method(name) { |id: nil| api.collection(dataset, id: id) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# create<Dataset>/update<Dataset>/delete<Dataset> with the same
|
|
67
|
+
# semantics (and schema validation) as the REST verbs.
|
|
68
|
+
def add_mutation_fields(mutation_type, name, type, dataset, api)
|
|
69
|
+
capitalized = name.sub(/\A./, &:upcase)
|
|
70
|
+
mutation_type.class_eval do
|
|
71
|
+
field "create#{capitalized}", type, null: true, camelize: false do
|
|
72
|
+
argument :item, JsonScalar, required: true
|
|
73
|
+
end
|
|
74
|
+
define_method("create#{capitalized}") { |item:| api.create_item(dataset, item: item) }
|
|
75
|
+
|
|
76
|
+
field "update#{capitalized}", type, null: true, camelize: false do
|
|
77
|
+
argument :id, String, required: true
|
|
78
|
+
argument :item, JsonScalar, required: true
|
|
79
|
+
end
|
|
80
|
+
define_method("update#{capitalized}") do |id:, item:|
|
|
81
|
+
api.update_item(dataset, id: id, item: item)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
field "delete#{capitalized}", GraphQL::Types::Boolean, null: false, camelize: false do
|
|
85
|
+
argument :id, String, required: true
|
|
86
|
+
end
|
|
87
|
+
define_method("delete#{capitalized}") { |id:| api.remove_item(dataset, id: id) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The item type inferred from the dataset's JSON schema (the
|
|
92
|
+
# schema describes the collection; its "items" describe an item),
|
|
93
|
+
# else the permissive JSON scalar.
|
|
94
|
+
def item_type_for(dataset, name)
|
|
95
|
+
properties = item_properties(dataset.json_schema)
|
|
96
|
+
return JsonScalar if properties.nil? || properties.empty?
|
|
97
|
+
|
|
98
|
+
fields = properties.to_h { |property, spec| [property, graphql_type_for(spec)] }
|
|
99
|
+
Class.new(GraphQL::Schema::Object) do
|
|
100
|
+
graphql_name("#{name.sub(/\A./, &:upcase)}Item")
|
|
101
|
+
fields.each do |property, type|
|
|
102
|
+
field property, type, null: true, camelize: false
|
|
103
|
+
define_method(property) { object[property] }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def item_properties(schema)
|
|
109
|
+
return nil unless schema.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
if schema["type"] == "array" && schema["items"].is_a?(Hash)
|
|
112
|
+
schema["items"]["properties"]
|
|
113
|
+
elsif schema["type"] == "object"
|
|
114
|
+
schema["properties"]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def graphql_type_for(spec)
|
|
119
|
+
case spec.is_a?(Hash) ? spec["type"] : nil
|
|
120
|
+
when "string" then String
|
|
121
|
+
when "integer" then Integer
|
|
122
|
+
when "number" then Float
|
|
123
|
+
when "boolean" then GraphQL::Types::Boolean
|
|
124
|
+
when "array" then [graphql_type_for(spec["items"] || {})]
|
|
125
|
+
else JsonScalar
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Dataset names become GraphQL identifiers ("my-data" -> "myData").
|
|
130
|
+
def graphql_name_for(name)
|
|
131
|
+
parts = name.split(/[^a-zA-Z0-9]+/).reject(&:empty?)
|
|
132
|
+
return "data" if parts.empty?
|
|
133
|
+
|
|
134
|
+
parts.first.downcase + parts.drop(1).map(&:capitalize).join
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "bcrypt"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# Apache htpasswd verification (05x-authentication). Supported hash
|
|
11
|
+
# formats:
|
|
12
|
+
# - bcrypt ($2a$/$2b$/$2y$, `htpasswd -B`) via the bcrypt gem
|
|
13
|
+
# - Apache apr1 MD5 ($apr1$, `htpasswd -m`) and md5-crypt ($1$),
|
|
14
|
+
# pure-Ruby md5-crypt
|
|
15
|
+
# - unsalted SHA-1 ({SHA}, `htpasswd -s`)
|
|
16
|
+
# - anything else (DES, $5$/$6$) via the platform's crypt(3) —
|
|
17
|
+
# support depends on the OS
|
|
18
|
+
class Htpasswd
|
|
19
|
+
# md5-crypt as deployed by Apache htpasswd, OpenSSL and glibc
|
|
20
|
+
# ($1$ and $apr1$ magics; identical except for the magic string).
|
|
21
|
+
# NOTE: Poul-Henning Kamp's original FreeBSD implementation mixed
|
|
22
|
+
# the magic in at the % 7 step where all deployed implementations
|
|
23
|
+
# mix in the password (see apr_md5.c, glibc crypt-md5.c).
|
|
24
|
+
module Md5Crypt
|
|
25
|
+
ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
26
|
+
DIGEST_GROUPS = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]].freeze
|
|
27
|
+
|
|
28
|
+
def self.verify(hash, password)
|
|
29
|
+
_prefix, magic, salt, = hash.split("$", 4)
|
|
30
|
+
return false if salt.nil? || salt.empty?
|
|
31
|
+
|
|
32
|
+
Reactor.secure_compare(digest(password, salt, "$#{magic}$"), hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.digest(password, salt, magic)
|
|
36
|
+
final = initial_digest(password, salt, magic)
|
|
37
|
+
1000.times do |i|
|
|
38
|
+
final = stretch_digest(i, password, salt, final)
|
|
39
|
+
end
|
|
40
|
+
"#{magic}#{salt}$#{to64(final)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.initial_digest(password, salt, magic)
|
|
44
|
+
inner = Digest::MD5.digest(password + salt + password)
|
|
45
|
+
context = Digest::MD5.new
|
|
46
|
+
context << password << magic << salt
|
|
47
|
+
length = password.length
|
|
48
|
+
while length.positive?
|
|
49
|
+
context << inner[0, [16, length].min]
|
|
50
|
+
length -= 16
|
|
51
|
+
end
|
|
52
|
+
length = password.length
|
|
53
|
+
while length.positive?
|
|
54
|
+
context << (length.odd? ? "\0" : password[0])
|
|
55
|
+
length >>= 1
|
|
56
|
+
end
|
|
57
|
+
context.digest
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.stretch_digest(iteration, password, salt, final)
|
|
61
|
+
context = Digest::MD5.new
|
|
62
|
+
context << (iteration.odd? ? password : final)
|
|
63
|
+
context << salt unless (iteration % 3).zero?
|
|
64
|
+
context << password unless (iteration % 7).zero?
|
|
65
|
+
context << (iteration.odd? ? final : password)
|
|
66
|
+
context.digest
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The custom base-64 of md5-crypt: digest triples permuted, first
|
|
70
|
+
# index to the high bits, 22 characters total.
|
|
71
|
+
def self.to64(final)
|
|
72
|
+
encoded = +""
|
|
73
|
+
DIGEST_GROUPS.each do |a, b, c|
|
|
74
|
+
value = final[c].ord | (final[b].ord << 8) | (final[a].ord << 16)
|
|
75
|
+
4.times do
|
|
76
|
+
encoded << ITOA64[value & 0x3f]
|
|
77
|
+
value >>= 6
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
value = final[11].ord
|
|
81
|
+
2.times do
|
|
82
|
+
encoded << ITOA64[value & 0x3f]
|
|
83
|
+
value >>= 6
|
|
84
|
+
end
|
|
85
|
+
encoded
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
attr_reader :path
|
|
90
|
+
|
|
91
|
+
def initialize(path)
|
|
92
|
+
raise Error, "htpasswd file not found: #{path}" unless File.file?(path)
|
|
93
|
+
|
|
94
|
+
@path = path
|
|
95
|
+
@entries = parse(path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def usernames
|
|
99
|
+
@entries.keys
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verify?(username, password)
|
|
103
|
+
hash = @entries[username]
|
|
104
|
+
return false unless hash
|
|
105
|
+
|
|
106
|
+
verify_hash(hash, password)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def parse(path)
|
|
112
|
+
File.readlines(path, chomp: true).each_with_object({}) do |line, entries|
|
|
113
|
+
next if line.strip.empty? || line.start_with?("#")
|
|
114
|
+
|
|
115
|
+
username, hash = line.split(":", 2)
|
|
116
|
+
entries[username] = hash
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def verify_hash(hash, password)
|
|
121
|
+
case hash
|
|
122
|
+
when /\A\$2[aby]\$/ then verify_bcrypt(hash, password)
|
|
123
|
+
when /\A\$(?:apr1|1)\$/ then Md5Crypt.verify(hash, password)
|
|
124
|
+
when /\A\{SHA\}/ then verify_sha1(hash, password)
|
|
125
|
+
else verify_crypt(hash, password)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The bcrypt gem predates htpasswd's $2y$ tag; the variants are
|
|
130
|
+
# algorithmically identical, normalized here.
|
|
131
|
+
def verify_bcrypt(hash, password)
|
|
132
|
+
BCrypt::Password.new(hash.sub(/\A\$2y\$/, "$2a$")) == password
|
|
133
|
+
rescue BCrypt::Errors::InvalidHash
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def verify_sha1(hash, password)
|
|
138
|
+
digest = "{SHA}#{Base64.strict_encode64(Digest::SHA1.digest(password))}"
|
|
139
|
+
Reactor.secure_compare(digest, hash)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def verify_crypt(hash, password)
|
|
143
|
+
computed = password.crypt(hash)
|
|
144
|
+
computed && Reactor.secure_compare(computed, hash)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Constant-time string comparison (length-check first).
|
|
149
|
+
def self.secure_compare(own, theirs)
|
|
150
|
+
own.bytesize == theirs.bytesize &&
|
|
151
|
+
OpenSSL.fixed_length_secure_compare(own, theirs)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|