capsium 0.1.2 → 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/.gitignore +1 -0
- data/.rubocop.yml +46 -7
- data/CHANGELOG.md +183 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +496 -127
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +12 -19
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +148 -0
- data/lib/capsium/cli/reactor.rb +33 -0
- data/lib/capsium/cli.rb +4 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -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/dataset.rb +67 -34
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +174 -44
- data/lib/capsium/package/security.rb +140 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +128 -3
- 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 +160 -0
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +92 -106
- data/lib/capsium/packager.rb +75 -6
- 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 +91 -0
- 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 +93 -43
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- 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/dataset.rbs +28 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +107 -0
- data/sig/capsium/package/security.rbs +72 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +85 -0
- 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/validator.rbs +29 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +61 -0
- data/sig/capsium/packager.rbs +32 -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/introspection.rbs +31 -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 +35 -0
- data/sig/capsium.rbs +3 -1
- metadata +108 -170
- data/lib/capsium/package/dataset_config.rb +0 -28
- data/lib/capsium/protector.rb +0 -103
data/lib/capsium/package.rb
CHANGED
|
@@ -1,89 +1,100 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# lib/capsium/package.rb
|
|
4
3
|
require "fileutils"
|
|
5
|
-
require "
|
|
6
|
-
require "
|
|
7
|
-
require "csv"
|
|
8
|
-
require "sqlite3"
|
|
9
|
-
require "zip"
|
|
10
|
-
require_relative "package/manifest"
|
|
11
|
-
require_relative "package/metadata"
|
|
12
|
-
require_relative "package/routes"
|
|
13
|
-
require_relative "package/dataset"
|
|
14
|
-
require_relative "package/storage"
|
|
15
|
-
require_relative "packager"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tmpdir"
|
|
16
6
|
|
|
17
7
|
module Capsium
|
|
18
8
|
class Package
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
autoload :Cipher, "capsium/package/cipher"
|
|
10
|
+
autoload :CircularDependencyError, "capsium/package/dependency_resolver"
|
|
11
|
+
autoload :Composition, "capsium/package/composition"
|
|
12
|
+
autoload :Authentication, "capsium/package/authentication"
|
|
13
|
+
autoload :AuthenticationConfig, "capsium/package/authentication_config"
|
|
14
|
+
autoload :AuthenticationData, "capsium/package/authentication_config"
|
|
15
|
+
autoload :BasicAuthConfig, "capsium/package/authentication_config"
|
|
16
|
+
autoload :Dataset, "capsium/package/dataset"
|
|
17
|
+
autoload :DatasetConfig, "capsium/package/storage_config"
|
|
18
|
+
autoload :DependencyError, "capsium/package/dependency_resolver"
|
|
19
|
+
autoload :DependencyNotFoundError, "capsium/package/dependency_resolver"
|
|
20
|
+
autoload :DependencyResolver, "capsium/package/dependency_resolver"
|
|
21
|
+
autoload :DependencyVisibilityError, "capsium/package/dependency_resolver"
|
|
22
|
+
autoload :DigitalSignatures, "capsium/package/security_config"
|
|
23
|
+
autoload :EncryptionConfig, "capsium/package/encryption_config"
|
|
24
|
+
autoload :EncryptionEnvelope, "capsium/package/encryption_config"
|
|
25
|
+
autoload :IntegrityChecks, "capsium/package/security_config"
|
|
26
|
+
autoload :LayerConfig, "capsium/package/storage_config"
|
|
27
|
+
autoload :Manifest, "capsium/package/manifest"
|
|
28
|
+
autoload :ManifestConfig, "capsium/package/manifest_config"
|
|
29
|
+
autoload :MergedView, "capsium/package/merged_view"
|
|
30
|
+
autoload :Metadata, "capsium/package/metadata"
|
|
31
|
+
autoload :MetadataData, "capsium/package/metadata_config"
|
|
32
|
+
autoload :OAuth2Config, "capsium/package/authentication_config"
|
|
33
|
+
autoload :Preparation, "capsium/package/preparation"
|
|
34
|
+
autoload :Repository, "capsium/package/metadata_config"
|
|
35
|
+
autoload :ResolvedDependency, "capsium/package/dependency_resolver"
|
|
36
|
+
autoload :Resource, "capsium/package/manifest_config"
|
|
37
|
+
autoload :ResponseRewrite, "capsium/package/routes_config"
|
|
38
|
+
autoload :Route, "capsium/package/routes_config"
|
|
39
|
+
autoload :Routes, "capsium/package/routes"
|
|
40
|
+
autoload :RoutesConfig, "capsium/package/routes_config"
|
|
41
|
+
autoload :Security, "capsium/package/security"
|
|
42
|
+
autoload :SecurityConfig, "capsium/package/security_config"
|
|
43
|
+
autoload :SecurityData, "capsium/package/security_config"
|
|
44
|
+
autoload :Signer, "capsium/package/signer"
|
|
45
|
+
autoload :Storage, "capsium/package/storage"
|
|
46
|
+
autoload :StorageConfig, "capsium/package/storage_config"
|
|
47
|
+
autoload :StorageData, "capsium/package/storage_config"
|
|
48
|
+
autoload :Store, "capsium/package/store"
|
|
49
|
+
autoload :Testing, "capsium/package/testing"
|
|
50
|
+
autoload :UnsatisfiableDependencyError, "capsium/package/dependency_resolver"
|
|
51
|
+
autoload :Validator, "capsium/package/validator"
|
|
52
|
+
autoload :Verification, "capsium/package/verification"
|
|
53
|
+
autoload :Version, "capsium/package/version"
|
|
54
|
+
autoload :VersionRange, "capsium/package/version"
|
|
55
|
+
|
|
56
|
+
include Verification
|
|
57
|
+
include Composition
|
|
58
|
+
include Preparation
|
|
59
|
+
|
|
60
|
+
attr_reader :name, :path, :manifest, :metadata, :routes, :storage,
|
|
61
|
+
:security, :load_type, :resolved_dependencies, :authentication
|
|
21
62
|
|
|
22
63
|
MANIFEST_FILE = "manifest.json"
|
|
23
64
|
METADATA_FILE = "metadata.json"
|
|
24
|
-
PACKAGING_FILE = "packaging.json"
|
|
25
|
-
SIGNATURE_FILE = "signature.json"
|
|
26
65
|
STORAGE_FILE = "storage.json"
|
|
27
66
|
ROUTES_FILE = "routes.json"
|
|
67
|
+
SECURITY_FILE = "security.json"
|
|
68
|
+
SIGNATURE_FILE = "signature.sig"
|
|
69
|
+
AUTHENTICATION_FILE = "authentication.json"
|
|
28
70
|
CONTENT_DIR = "content"
|
|
29
71
|
DATA_DIR = "data"
|
|
30
|
-
ENCRYPTED_PACKAGING_FILE = "package.enc"
|
|
31
72
|
|
|
32
|
-
|
|
73
|
+
# Loads a package directory or .cap file. Composite packages
|
|
74
|
+
# (metadata.dependencies, ARCHITECTURE.md section 4a) resolve against
|
|
75
|
+
# the package store given as `store` (directory path or Store) or via
|
|
76
|
+
# CAPSIUM_STORE. `dependency_chain` is internal: the ancestor GUIDs
|
|
77
|
+
# used for circular-dependency detection during recursive resolution.
|
|
78
|
+
def initialize(path, load_type: nil, decryption_key: nil, store: nil,
|
|
79
|
+
dependency_chain: [])
|
|
80
|
+
@decryption_key = decryption_key
|
|
33
81
|
@original_path = Pathname.new(path).expand_path
|
|
34
|
-
@path = prepare_package(@original_path)
|
|
82
|
+
@path = prepare_package(@original_path).to_s
|
|
35
83
|
@load_type = load_type || determine_load_type(path)
|
|
36
84
|
create_package_structure
|
|
37
85
|
load_package
|
|
38
86
|
@name = metadata.name
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if File.file?(path)
|
|
45
|
-
return decompress_cap_file(path) if File.extname(path) == ".cap"
|
|
46
|
-
|
|
47
|
-
raise "Error: The package must have a .cap extension"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
raise "Invalid package path: #{path}"
|
|
87
|
+
@resolved_dependencies = resolve_dependencies(store, dependency_chain)
|
|
88
|
+
validate_dependency_references!
|
|
89
|
+
verify_integrity!
|
|
90
|
+
verify_signature!
|
|
51
91
|
end
|
|
52
92
|
|
|
53
93
|
def solidify
|
|
54
94
|
@manifest.save_to_file
|
|
55
95
|
@metadata.save_to_file
|
|
56
96
|
@routes.save_to_file
|
|
57
|
-
@storage.save_to_file
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def decompress_cap_file(file_path)
|
|
61
|
-
temp_dir = Dir.mktmpdir
|
|
62
|
-
metadata_path = File.join(temp_dir, METADATA_FILE)
|
|
63
|
-
|
|
64
|
-
# Extract metadata.json first
|
|
65
|
-
Zip::File.open(file_path) do |zip_file|
|
|
66
|
-
if entry = zip_file.find_entry(METADATA_FILE)
|
|
67
|
-
entry.extract(metadata_path)
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
metadata = Metadata.new(metadata_path)
|
|
72
|
-
package_name = metadata.name
|
|
73
|
-
package_version = metadata.version
|
|
74
|
-
|
|
75
|
-
package_path = File.join(temp_dir, "#{package_name}-#{package_version}")
|
|
76
|
-
FileUtils.mkdir_p(package_path)
|
|
77
|
-
|
|
78
|
-
Zip::File.open(file_path) do |zip_file|
|
|
79
|
-
zip_file.each do |entry|
|
|
80
|
-
entry_path = File.join(package_path, entry.name)
|
|
81
|
-
FileUtils.mkdir_p(File.dirname(entry_path))
|
|
82
|
-
entry.extract(entry_path)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
package_path
|
|
97
|
+
@storage.save_to_file unless @storage.empty?
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
def load_package
|
|
@@ -94,61 +105,36 @@ module Capsium
|
|
|
94
105
|
@manifest = Manifest.new(manifest_path)
|
|
95
106
|
@storage = Storage.new(storage_path)
|
|
96
107
|
@routes = Routes.new(routes_path, @manifest, @storage)
|
|
108
|
+
@security = Security.new(security_path)
|
|
109
|
+
@authentication = Authentication.new(authentication_path)
|
|
97
110
|
end
|
|
98
111
|
|
|
99
112
|
def cleanup
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
FileUtils.remove_entry(@path)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def package_files
|
|
106
|
-
@packager.package_files
|
|
113
|
+
@resolved_dependencies.each { |dependency| dependency.package.cleanup }
|
|
114
|
+
FileUtils.remove_entry(@path) if @path != @original_path.to_s && File.directory?(@path)
|
|
107
115
|
end
|
|
108
116
|
|
|
109
|
-
def
|
|
110
|
-
Dir.glob(File.join(content_path, "**", "*")).select do |file|
|
|
111
|
-
File.file?(file)
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def determine_load_type(path)
|
|
116
|
-
return :directory if File.directory?(path)
|
|
117
|
-
return :cap_file if File.extname(path) == ".cap"
|
|
118
|
-
|
|
119
|
-
:unsaved
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
private
|
|
123
|
-
|
|
124
|
-
def create_package_structure
|
|
125
|
-
FileUtils.mkdir_p(@path)
|
|
126
|
-
FileUtils.mkdir_p(content_path)
|
|
127
|
-
FileUtils.mkdir_p(data_path)
|
|
128
|
-
end
|
|
117
|
+
def datasets = storage.datasets
|
|
129
118
|
|
|
130
|
-
|
|
131
|
-
|
|
119
|
+
# The merged content view across the storage layers (ARCHITECTURE.md
|
|
120
|
+
# section 5a); exported_only gives the dependent-package view (section 4a).
|
|
121
|
+
# Resolved dependencies act as read-only layers below all own layers.
|
|
122
|
+
def merged_view(exported_only: false)
|
|
123
|
+
@merged_views ||= {}
|
|
124
|
+
@merged_views[exported_only] ||=
|
|
125
|
+
MergedView.new(@path, storage: @storage, manifest: @manifest,
|
|
126
|
+
dependency_views: dependency_views,
|
|
127
|
+
exported_only: exported_only)
|
|
132
128
|
end
|
|
133
129
|
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
# The .cap file this package was loaded from, or nil when loaded
|
|
131
|
+
# from a directory.
|
|
132
|
+
def cap_file_path
|
|
133
|
+
@original_path.to_s if load_type == :cap_file
|
|
136
134
|
end
|
|
137
135
|
|
|
138
|
-
def
|
|
139
|
-
File.join(
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def storage_path
|
|
143
|
-
File.join(@path, STORAGE_FILE)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def metadata_path
|
|
147
|
-
File.join(@path, METADATA_FILE)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def manifest_path
|
|
151
|
-
File.join(@path, MANIFEST_FILE)
|
|
136
|
+
def content_files
|
|
137
|
+
Dir.glob(File.join(content_path, "**", "*")).select { |file| File.file?(file) }
|
|
152
138
|
end
|
|
153
139
|
end
|
|
154
140
|
end
|
data/lib/capsium/packager.rb
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# lib/capsium/packager.rb
|
|
4
|
-
require "json"
|
|
5
3
|
require "fileutils"
|
|
6
|
-
require "
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tmpdir"
|
|
7
6
|
require "zip"
|
|
8
7
|
|
|
9
8
|
module Capsium
|
|
10
9
|
class Packager
|
|
11
10
|
class FileAlreadyExistsError < StandardError; end
|
|
12
11
|
|
|
12
|
+
# Raised when a .cap archive contains an entry whose name would be
|
|
13
|
+
# written outside the destination directory (absolute paths, drive
|
|
14
|
+
# letters, ".." segments) — zip-slip protection, on par with the Lua
|
|
15
|
+
# reactor's extractor.
|
|
16
|
+
class UnsafeEntryError < Capsium::Error; end
|
|
17
|
+
|
|
18
|
+
DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
|
|
19
|
+
|
|
13
20
|
def pack(package, options = {})
|
|
14
21
|
directory = package.path
|
|
15
22
|
output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
|
|
@@ -26,8 +33,10 @@ module Capsium
|
|
|
26
33
|
|
|
27
34
|
Dir.mktmpdir do |dir|
|
|
28
35
|
FileUtils.cp_r("#{directory}/.", dir)
|
|
36
|
+
strip_security_artifacts(dir)
|
|
29
37
|
new_package = Package.new(dir)
|
|
30
38
|
new_package.solidify
|
|
39
|
+
generate_security(new_package)
|
|
31
40
|
new_cap_file_path = File.join(dir, output_file_name)
|
|
32
41
|
compress_package(new_package, new_cap_file_path)
|
|
33
42
|
puts "Package built at: #{new_cap_file_path}"
|
|
@@ -38,9 +47,10 @@ module Capsium
|
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
def unpack(cap_file_path, destination)
|
|
50
|
+
destination = File.expand_path(destination)
|
|
41
51
|
Zip::File.open(cap_file_path) do |zip_file|
|
|
42
52
|
zip_file.each do |entry|
|
|
43
|
-
entry_path =
|
|
53
|
+
entry_path = safe_entry_path(destination, entry.name)
|
|
44
54
|
FileUtils.mkdir_p(File.dirname(entry_path))
|
|
45
55
|
entry.extract(entry_path)
|
|
46
56
|
end
|
|
@@ -48,15 +58,74 @@ module Capsium
|
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
def compress_package(package, cap_file)
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
entries = Dir[File.join(package.path, "**", "**")].reject do |file|
|
|
62
|
+
File.expand_path(file) == File.expand_path(cap_file)
|
|
63
|
+
end
|
|
64
|
+
Zip::File.open(cap_file, create: true) do |zipfile|
|
|
65
|
+
entries.each do |file|
|
|
53
66
|
zipfile.add(file.sub("#{package.path}/", ""), file)
|
|
54
67
|
end
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
|
|
71
|
+
# Unpacks a .cap into a temporary directory, yields it for read-only
|
|
72
|
+
# inspection and returns the block's value.
|
|
73
|
+
def with_unpacked_cap(cap_path)
|
|
74
|
+
Dir.mktmpdir do |dir|
|
|
75
|
+
unpack(cap_path, dir)
|
|
76
|
+
yield dir
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Unpacks a .cap into a temporary directory, yields it for
|
|
81
|
+
# modification, then recompresses the result back over cap_path.
|
|
82
|
+
# The modified package is loaded (verifying integrity and any
|
|
83
|
+
# declared signature) before recompressing.
|
|
84
|
+
def transform_cap(cap_path)
|
|
85
|
+
with_unpacked_cap(cap_path) do |dir|
|
|
86
|
+
yield dir
|
|
87
|
+
Dir.mktmpdir do |tmp|
|
|
88
|
+
tmp_cap = File.join(tmp, File.basename(cap_path))
|
|
89
|
+
compress_package(Package.new(dir), tmp_cap)
|
|
90
|
+
FileUtils.mv(tmp_cap, cap_path)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
cap_path
|
|
94
|
+
end
|
|
95
|
+
|
|
58
96
|
def relative_path_current(absolute_path)
|
|
59
97
|
Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
|
|
60
98
|
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Resolves an entry name against the destination and returns the
|
|
103
|
+
# absolute target path, raising UnsafeEntryError when the entry would
|
|
104
|
+
# escape the destination (zip-slip).
|
|
105
|
+
def safe_entry_path(destination, entry_name)
|
|
106
|
+
entry_path = File.expand_path(entry_name, destination)
|
|
107
|
+
return entry_path if contained?(entry_path, destination) &&
|
|
108
|
+
!entry_name.match?(DRIVE_LETTER_PATTERN)
|
|
109
|
+
|
|
110
|
+
raise UnsafeEntryError,
|
|
111
|
+
"Refusing to extract unsafe zip entry: #{entry_name}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def contained?(entry_path, destination)
|
|
115
|
+
entry_path.start_with?("#{destination}#{File::SEPARATOR}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def generate_security(package)
|
|
119
|
+
security = Package::Security.generate(package.path)
|
|
120
|
+
security.save_to_file
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# security.json is regenerated on pack; signing artifacts are dropped
|
|
124
|
+
# because signing is a post-pack step (`capsium package sign`).
|
|
125
|
+
def strip_security_artifacts(directory)
|
|
126
|
+
FileUtils.rm_f(File.join(directory, Package::SECURITY_FILE))
|
|
127
|
+
FileUtils.rm_f(File.join(directory, Package::SIGNATURE_FILE))
|
|
128
|
+
FileUtils.rm_f(File.join(directory, Package::Signer::PUBLIC_KEY_FILE))
|
|
129
|
+
end
|
|
61
130
|
end
|
|
62
131
|
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Request authentication and route authorization for the reactor
|
|
8
|
+
# (05x-authentication, ARCHITECTURE.md section 4b).
|
|
9
|
+
#
|
|
10
|
+
# When the package's authentication.json enables basicAuth, every
|
|
11
|
+
# route is challenged (401 + WWW-Authenticate) until valid htpasswd
|
|
12
|
+
# credentials arrive. When oauth2 is enabled, the login and callback
|
|
13
|
+
# endpoints run the authorization-code flow and establish a signed
|
|
14
|
+
# session cookie. Route-level accessControl is enforced after
|
|
15
|
+
# authentication: 401 when unauthenticated, 403 when the identity
|
|
16
|
+
# lacks a required role.
|
|
17
|
+
class Authenticator
|
|
18
|
+
LOGIN_PATH = "/auth/login"
|
|
19
|
+
|
|
20
|
+
attr_reader :authentication, :deploy
|
|
21
|
+
|
|
22
|
+
def initialize(authentication, deploy:, package_path:, base_url: nil,
|
|
23
|
+
state_file: nil)
|
|
24
|
+
@authentication = authentication
|
|
25
|
+
@deploy = deploy
|
|
26
|
+
@package_path = package_path
|
|
27
|
+
@base_url = base_url
|
|
28
|
+
@state_file = state_file
|
|
29
|
+
build_oauth2_flow if oauth2_enabled?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
@authentication.enabled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The paths this authenticator answers itself (OAuth2 login and
|
|
37
|
+
# callback), mounted by the reactor.
|
|
38
|
+
def endpoints
|
|
39
|
+
oauth2_enabled? ? [LOGIN_PATH, @authentication.oauth2.redirect_path] : []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def endpoint?(path)
|
|
43
|
+
endpoints.include?(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def serve_endpoint(request, response)
|
|
47
|
+
if request.path == LOGIN_PATH
|
|
48
|
+
response.status = 302
|
|
49
|
+
response["Location"] = @oauth2_flow.authorization_url(request)
|
|
50
|
+
response.body = "Redirecting to the identity provider"
|
|
51
|
+
else
|
|
52
|
+
handle_callback(request, response)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The identity for a request — from the session cookie or Basic
|
|
57
|
+
# credentials — or nil. An identity always carries "roles"
|
|
58
|
+
# (possibly empty).
|
|
59
|
+
def authenticate(request)
|
|
60
|
+
identity = @session&.identity_from(request)
|
|
61
|
+
identity || authenticate_basic(request)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 401, with a Basic challenge when basicAuth is enabled.
|
|
65
|
+
def challenge(response)
|
|
66
|
+
response.status = 401
|
|
67
|
+
response["Content-Type"] = "text/plain"
|
|
68
|
+
response["WWW-Authenticate"] = %(Basic realm="#{realm}") if basic_enabled?
|
|
69
|
+
response.body = "Unauthorized"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# :ok, :unauthenticated or :forbidden for a route's accessControl
|
|
73
|
+
# ({"roles": [...], "authenticationRequired": bool}).
|
|
74
|
+
def authorize(identity, access_control)
|
|
75
|
+
return :ok unless access_control
|
|
76
|
+
return :unauthenticated if unauthenticated?(identity, access_control)
|
|
77
|
+
return :forbidden unless roles_allowed?(identity, access_control["roles"])
|
|
78
|
+
|
|
79
|
+
:ok
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def unauthenticated?(identity, access_control)
|
|
85
|
+
access_control.fetch("authenticationRequired", true) && identity.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def roles_allowed?(identity, roles)
|
|
89
|
+
return true if roles.nil? || roles.empty?
|
|
90
|
+
return false if identity.nil?
|
|
91
|
+
|
|
92
|
+
roles.intersect?(identity["roles"])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def basic_enabled? = !!@authentication.basic_auth&.enabled?
|
|
96
|
+
|
|
97
|
+
def oauth2_enabled? = !!@authentication.oauth2&.enabled?
|
|
98
|
+
|
|
99
|
+
def realm = @authentication.basic_auth&.realm || "capsium"
|
|
100
|
+
|
|
101
|
+
def authenticate_basic(request)
|
|
102
|
+
return unless basic_enabled?
|
|
103
|
+
|
|
104
|
+
credentials = basic_credentials(request["Authorization"])
|
|
105
|
+
return unless credentials
|
|
106
|
+
|
|
107
|
+
username, password = credentials
|
|
108
|
+
return unless htpasswd.verify?(username, password)
|
|
109
|
+
|
|
110
|
+
{ "name" => username, "roles" => Array(@deploy.roles[username]) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def basic_credentials(header)
|
|
114
|
+
return unless header&.start_with?("Basic ")
|
|
115
|
+
|
|
116
|
+
Base64.decode64(header.delete_prefix("Basic ")).split(":", 2)
|
|
117
|
+
rescue ArgumentError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def htpasswd
|
|
122
|
+
@htpasswd ||= Htpasswd.new(htpasswd_path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# deploy.json's basicAuth.passwdFile overrides the package's
|
|
126
|
+
# (reactor-side secret storage); the package's passwdFile is
|
|
127
|
+
# package-relative otherwise.
|
|
128
|
+
def htpasswd_path
|
|
129
|
+
@deploy.passwd_file ||
|
|
130
|
+
File.join(@package_path, @authentication.basic_auth.passwd_file.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_oauth2_flow
|
|
134
|
+
secret = @deploy.client_secret
|
|
135
|
+
if secret.nil? || secret.empty?
|
|
136
|
+
raise Error,
|
|
137
|
+
"oauth2 is enabled but no clientSecret is configured " \
|
|
138
|
+
"(deploy.json or CAPSIUM_DEPLOY; never in the package)"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@session = Session.new(secret: @deploy.session_secret,
|
|
142
|
+
state_file: @state_file)
|
|
143
|
+
@oauth2_flow = OAuth2.new(@authentication.oauth2, client_secret: secret,
|
|
144
|
+
session: @session,
|
|
145
|
+
base_url: @base_url)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def handle_callback(request, response)
|
|
149
|
+
query = request.query
|
|
150
|
+
return respond_invalid_state(response) unless @oauth2_flow.valid_state?(query["state"])
|
|
151
|
+
|
|
152
|
+
userinfo = @oauth2_flow.complete(query["code"], request)
|
|
153
|
+
response.status = 302
|
|
154
|
+
response["Location"] = "/"
|
|
155
|
+
response["Set-Cookie"] = @session.cookie_for(identity_from(userinfo))
|
|
156
|
+
response.body = "Authenticated"
|
|
157
|
+
rescue OAuth2::FlowError => e
|
|
158
|
+
response.status = 502
|
|
159
|
+
response["Content-Type"] = "text/plain"
|
|
160
|
+
response.body = "OAuth2 provider error: #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def respond_invalid_state(response)
|
|
164
|
+
response.status = 401
|
|
165
|
+
response["Content-Type"] = "text/plain"
|
|
166
|
+
response.body = "Invalid OAuth2 state"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def identity_from(userinfo)
|
|
170
|
+
name = userinfo["email"] || userinfo["sub"] || userinfo["name"]
|
|
171
|
+
{
|
|
172
|
+
"sub" => userinfo["sub"] || userinfo["id"],
|
|
173
|
+
"email" => userinfo["email"],
|
|
174
|
+
"name" => userinfo["name"],
|
|
175
|
+
"roles" => Array(userinfo["roles"]) | Array(@deploy.roles[name])
|
|
176
|
+
}.compact
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
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
|