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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -1,89 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package.rb
4
3
  require "fileutils"
5
- require "json"
6
- require "yaml"
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
- attr_reader :name, :path, :manifest, :metadata, :routes, :datasets,
20
- :storage, :load_type
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
- def initialize(path, load_type: nil)
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
- end
40
-
41
- def prepare_package(path)
42
- return path if File.directory?(path)
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
- return unless @path != @original_path && File.directory?(@path)
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 content_files
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
- def content_path
131
- File.join(@path, CONTENT_DIR)
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
- def data_path
135
- File.join(@path, DATA_DIR)
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 routes_path
139
- File.join(@path, ROUTES_FILE)
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
@@ -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 "rubygems"
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 = File.join(destination, entry.name)
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
- Zip::File.open(cap_file, Zip::File::CREATE) do |zipfile|
52
- Dir[File.join(package.path, "**", "**")].each do |file|
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