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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -1
  3. data/CHANGELOG.md +322 -0
  4. data/README.adoc +643 -7
  5. data/capsium.gemspec +3 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +106 -7
  8. data/lib/capsium/cli/reactor.rb +120 -6
  9. data/lib/capsium/cli.rb +30 -0
  10. data/lib/capsium/log_buffer.rb +47 -0
  11. data/lib/capsium/package/authentication.rb +38 -0
  12. data/lib/capsium/package/authentication_config.rb +78 -0
  13. data/lib/capsium/package/bundle.rb +136 -0
  14. data/lib/capsium/package/cipher.rb +253 -0
  15. data/lib/capsium/package/composition.rb +93 -0
  16. data/lib/capsium/package/dataset.rb +20 -0
  17. data/lib/capsium/package/dependency_resolver.rb +91 -0
  18. data/lib/capsium/package/encryption_config.rb +42 -0
  19. data/lib/capsium/package/manifest.rb +1 -1
  20. data/lib/capsium/package/merged_view.rb +170 -0
  21. data/lib/capsium/package/metadata.rb +1 -1
  22. data/lib/capsium/package/metadata_config.rb +9 -1
  23. data/lib/capsium/package/open_pgp.rb +65 -0
  24. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  25. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  26. data/lib/capsium/package/preparation.rb +81 -0
  27. data/lib/capsium/package/routes_config.rb +60 -6
  28. data/lib/capsium/package/security.rb +21 -4
  29. data/lib/capsium/package/security_config.rb +6 -2
  30. data/lib/capsium/package/signer.rb +216 -0
  31. data/lib/capsium/package/storage_config.rb +29 -1
  32. data/lib/capsium/package/store.rb +136 -0
  33. data/lib/capsium/package/testing/config_test.rb +70 -0
  34. data/lib/capsium/package/testing/context.rb +11 -0
  35. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  36. data/lib/capsium/package/testing/file_test.rb +45 -0
  37. data/lib/capsium/package/testing/report.rb +33 -0
  38. data/lib/capsium/package/testing/route_test.rb +64 -0
  39. data/lib/capsium/package/testing/test_case.rb +60 -0
  40. data/lib/capsium/package/testing/test_suite.rb +86 -0
  41. data/lib/capsium/package/testing.rb +20 -0
  42. data/lib/capsium/package/validator.rb +39 -3
  43. data/lib/capsium/package/verification.rb +34 -0
  44. data/lib/capsium/package/version.rb +204 -0
  45. data/lib/capsium/package.rb +78 -75
  46. data/lib/capsium/packager.rb +97 -22
  47. data/lib/capsium/reactor/authenticator.rb +180 -0
  48. data/lib/capsium/reactor/content_api.rb +110 -0
  49. data/lib/capsium/reactor/data_api.rb +154 -0
  50. data/lib/capsium/reactor/deploy.rb +71 -0
  51. data/lib/capsium/reactor/endpoints.rb +37 -0
  52. data/lib/capsium/reactor/graphql_api.rb +123 -0
  53. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  54. data/lib/capsium/reactor/htpasswd.rb +154 -0
  55. data/lib/capsium/reactor/introspection.rb +151 -31
  56. data/lib/capsium/reactor/metrics.rb +30 -0
  57. data/lib/capsium/reactor/mount.rb +206 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/overlay.rb +248 -0
  60. data/lib/capsium/reactor/package_saver.rb +99 -0
  61. data/lib/capsium/reactor/responses.rb +41 -0
  62. data/lib/capsium/reactor/serving.rb +154 -0
  63. data/lib/capsium/reactor/session.rb +82 -0
  64. data/lib/capsium/reactor.rb +116 -67
  65. data/lib/capsium/registry/local.rb +120 -0
  66. data/lib/capsium/registry/remote.rb +105 -0
  67. data/lib/capsium/registry.rb +190 -0
  68. data/lib/capsium/thor_ext.rb +1 -1
  69. data/lib/capsium/version.rb +1 -1
  70. data/lib/capsium.rb +2 -0
  71. data/sig/capsium/log_buffer.rbs +30 -0
  72. data/sig/capsium/package/authentication.rbs +23 -0
  73. data/sig/capsium/package/authentication_config.rbs +71 -0
  74. data/sig/capsium/package/bundle.rbs +43 -0
  75. data/sig/capsium/package/cipher.rbs +62 -0
  76. data/sig/capsium/package/composition.rbs +21 -0
  77. data/sig/capsium/package/dataset.rbs +8 -0
  78. data/sig/capsium/package/dependency_resolver.rbs +53 -0
  79. data/sig/capsium/package/encryption_config.rbs +39 -0
  80. data/sig/capsium/package/merged_view.rbs +47 -0
  81. data/sig/capsium/package/metadata.rbs +1 -0
  82. data/sig/capsium/package/open_pgp.rbs +38 -0
  83. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  84. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  85. data/sig/capsium/package/preparation.rbs +23 -0
  86. data/sig/capsium/package/routes_config.rbs +36 -3
  87. data/sig/capsium/package/security.rbs +9 -2
  88. data/sig/capsium/package/security_config.rbs +6 -3
  89. data/sig/capsium/package/signer.rbs +66 -0
  90. data/sig/capsium/package/storage_config.rbs +26 -2
  91. data/sig/capsium/package/store.rbs +43 -0
  92. data/sig/capsium/package/testing/config_test.rbs +21 -0
  93. data/sig/capsium/package/testing/context.rbs +14 -0
  94. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  95. data/sig/capsium/package/testing/file_test.rbs +17 -0
  96. data/sig/capsium/package/testing/report.rbs +20 -0
  97. data/sig/capsium/package/testing/route_test.rbs +22 -0
  98. data/sig/capsium/package/testing/test_case.rbs +41 -0
  99. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  100. data/sig/capsium/package/testing.rbs +7 -0
  101. data/sig/capsium/package/verification.rbs +26 -0
  102. data/sig/capsium/package/version.rbs +39 -0
  103. data/sig/capsium/package.rbs +29 -16
  104. data/sig/capsium/packager.rbs +9 -0
  105. data/sig/capsium/reactor/authenticator.rbs +40 -0
  106. data/sig/capsium/reactor/content_api.rbs +19 -0
  107. data/sig/capsium/reactor/data_api.rbs +23 -0
  108. data/sig/capsium/reactor/deploy.rbs +34 -0
  109. data/sig/capsium/reactor/endpoints.rbs +16 -0
  110. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  111. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  112. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  113. data/sig/capsium/reactor/introspection.rbs +27 -7
  114. data/sig/capsium/reactor/metrics.rbs +13 -0
  115. data/sig/capsium/reactor/mount.rbs +83 -0
  116. data/sig/capsium/reactor/oauth2.rbs +31 -0
  117. data/sig/capsium/reactor/overlay.rbs +72 -0
  118. data/sig/capsium/reactor/package_saver.rbs +12 -0
  119. data/sig/capsium/reactor/responses.rbs +23 -0
  120. data/sig/capsium/reactor/serving.rbs +46 -0
  121. data/sig/capsium/reactor/session.rbs +36 -0
  122. data/sig/capsium/reactor.rbs +22 -2
  123. data/sig/capsium/registry/local.rbs +16 -0
  124. data/sig/capsium/registry/remote.rbs +18 -0
  125. data/sig/capsium/registry.rbs +83 -0
  126. data/sig/graphql.rbs +12 -0
  127. 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