machina-auth 0.1.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 +7 -0
- data/Gemfile +5 -0
- data/README.md +54 -0
- data/app/controllers/machina/webhooks_controller.rb +14 -0
- data/config/routes.rb +5 -0
- data/lib/generators/machina/install_generator.rb +32 -0
- data/lib/generators/machina/templates/create_machina_workspace_refs.rb.tt +13 -0
- data/lib/generators/machina/templates/machina.rb.tt +9 -0
- data/lib/generators/machina/templates/machina.yml.tt +8 -0
- data/lib/machina/authorized.rb +86 -0
- data/lib/machina/configuration.rb +19 -0
- data/lib/machina/controller_helpers.rb +67 -0
- data/lib/machina/current.rb +8 -0
- data/lib/machina/engine.rb +33 -0
- data/lib/machina/errors.rb +12 -0
- data/lib/machina/identity_client.rb +63 -0
- data/lib/machina/middleware/authentication.rb +94 -0
- data/lib/machina/permission_sync.rb +27 -0
- data/lib/machina/tasks/sync.rake +9 -0
- data/lib/machina/test_helpers.rb +134 -0
- data/lib/machina/version.rb +5 -0
- data/lib/machina/webhook_receiver.rb +83 -0
- data/lib/machina/workspace_ref.rb +9 -0
- data/lib/machina/workspace_scoped.rb +20 -0
- data/lib/machina.rb +68 -0
- data/machina-auth.gemspec +43 -0
- data/spec/contracts/permissions_sync_contract_spec.rb +59 -0
- data/spec/contracts/session_resolution_contract_spec.rb +86 -0
- data/spec/contracts/session_revocation_contract_spec.rb +52 -0
- data/spec/contracts/webhook_signature_contract_spec.rb +67 -0
- data/spec/dummy/app/models/application_record.rb +5 -0
- data/spec/dummy/app/models/current.rb +4 -0
- data/spec/dummy/config/application.rb +17 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +3 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/test.rb +11 -0
- data/spec/dummy/config/routes.rb +7 -0
- data/spec/dummy/db/schema.rb +11 -0
- data/spec/fixtures/machina.yml +9 -0
- data/spec/machina/authorized_spec.rb +161 -0
- data/spec/machina/configuration_spec.rb +15 -0
- data/spec/machina/controller_helpers_spec.rb +100 -0
- data/spec/machina/identity_client_spec.rb +69 -0
- data/spec/machina/middleware/authentication_spec.rb +71 -0
- data/spec/machina/permission_sync_spec.rb +29 -0
- data/spec/machina/test_helpers_spec.rb +84 -0
- data/spec/machina/webhook_receiver_spec.rb +93 -0
- data/spec/machina/workspace_scoped_spec.rb +34 -0
- data/spec/rails_helper.rb +34 -0
- data/spec/requests/machina/webhooks_spec.rb +31 -0
- data/spec/spec_helper.rb +3 -0
- data/spec/support/console_schema.rb +73 -0
- data/spec/support/mock_responses.rb +73 -0
- metadata +208 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3584cdd6299562928d19a2f67d7472a8db478b919c7bf8a18ec9cb36ceefa634
|
|
4
|
+
data.tar.gz: 1aed243a37779a2758182c91146e6a90fd5d966cc6cb935f4d1e55859b142281
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 979b7056a9721e024d55603d0056fe8fbb327ee69db0276c19c8738506767b26c29bd8312e507b980f7df16a19d3d9618da2a733e76e54ce41e463e8557ec78e
|
|
7
|
+
data.tar.gz: 5034866ddae72f5aeeb11ac67e2a8b665b9f3e4ef1f5319cd4de9e091c554a5bb59787a2999ce24f3b39ee77aa74a5f06c521440a9f1adba9a00c9fdd8dbd18e
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# machina-auth
|
|
2
|
+
|
|
3
|
+
Rails engine that integrates product apps with the Machina Console identity service.
|
|
4
|
+
|
|
5
|
+
## What It Provides
|
|
6
|
+
|
|
7
|
+
- **Authentication middleware** — extracts session tokens from cookies, headers, or params and resolves them against the Console
|
|
8
|
+
- **`Machina::Authorized`** — frozen value object with `can?`, `cannot?`, `authorize!`, and permission query methods
|
|
9
|
+
- **`Machina::Current`** — thread-safe current attributes (user, org, workspace, session)
|
|
10
|
+
- **`Machina::ControllerHelpers`** — `require_authorized!` and `authorize!` for controllers
|
|
11
|
+
- **`Machina::WorkspaceScoped`** — concern that filters ActiveRecord queries to the current workspace
|
|
12
|
+
- **Webhook receiver** — verifies HMAC signatures and invalidates cached sessions on permission/membership changes
|
|
13
|
+
- **Permission sync** — pushes a YAML manifest of permissions and policies to the Console on boot
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'machina-auth', path: '../gems/auth'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run the install generator:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bin/rails generate machina:install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
Machina.configure do |config|
|
|
33
|
+
config.identity_service_url = "http://localhost:3100"
|
|
34
|
+
config.service_token = ENV["MACHINA_SERVICE_TOKEN"]
|
|
35
|
+
config.product_slug = "my-app"
|
|
36
|
+
config.manifest = Rails.root.join("config/machina.yml")
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd gems/auth
|
|
44
|
+
bundle install
|
|
45
|
+
bundle exec rspec # 79 specs
|
|
46
|
+
bundle exec rubocop # lint
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Tests use a dummy Rails app in `spec/dummy/` with an in-memory SQLite database.
|
|
50
|
+
|
|
51
|
+
## Dependencies
|
|
52
|
+
|
|
53
|
+
- `rails ~> 8.0.4`
|
|
54
|
+
- `faraday` (HTTP client for Console API calls)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Receives incoming webhook events from the Machina Console and delegates
|
|
5
|
+
# processing to WebhookReceiver.
|
|
6
|
+
class WebhooksController < ActionController::API
|
|
7
|
+
def create
|
|
8
|
+
receiver = Machina::WebhookReceiver.new(request)
|
|
9
|
+
return head :unauthorized unless receiver.process!
|
|
10
|
+
|
|
11
|
+
head :ok
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module Machina
|
|
6
|
+
# Rails generators for scaffolding Machina configuration files.
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates initializer, manifest, and migration files needed to integrate
|
|
9
|
+
# a Rails application with the Machina auth system.
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
|
12
|
+
|
|
13
|
+
def create_initializer
|
|
14
|
+
template 'machina.rb.tt', 'config/initializers/machina.rb'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_manifest
|
|
18
|
+
template 'machina.yml.tt', 'config/machina.yml'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def copy_migration
|
|
22
|
+
template 'create_machina_workspace_refs.rb.tt', "db/migrate/#{timestamp}_create_machina_workspace_refs.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def timestamp
|
|
28
|
+
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateMachinaWorkspaceRefs < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :machina_workspace_refs do |t|
|
|
4
|
+
t.string :tenant_ref, null: false
|
|
5
|
+
t.string :organization_id
|
|
6
|
+
t.string :name
|
|
7
|
+
t.datetime :cached_at
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :machina_workspace_refs, :tenant_ref, unique: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Machina.configure do |config|
|
|
2
|
+
config.identity_service_url = ENV.fetch("MACHINA_IDENTITY_URL")
|
|
3
|
+
config.service_token = ENV.fetch("MACHINA_SERVICE_TOKEN")
|
|
4
|
+
config.product_id = ENV.fetch("MACHINA_PRODUCT_ID")
|
|
5
|
+
config.product_slug = "<%= Rails.application.class.module_parent_name.underscore %>"
|
|
6
|
+
config.cache_store = Rails.cache
|
|
7
|
+
config.cache_ttl = 5.minutes
|
|
8
|
+
config.manifest = Rails.root.join("config/machina.yml")
|
|
9
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Immutable value object representing the currently authenticated user,
|
|
5
|
+
# their organization/workspace context, and granted permissions.
|
|
6
|
+
class Authorized
|
|
7
|
+
attr_reader :user_id, :user_email, :user_name, :avatar_url,
|
|
8
|
+
:organization_id, :org_name, :org_personal, :org_role,
|
|
9
|
+
:workspace_id, :workspace_name,
|
|
10
|
+
:session_id, :expires_at, :type
|
|
11
|
+
|
|
12
|
+
def initialize(data = {})
|
|
13
|
+
assign_user_attrs(data)
|
|
14
|
+
assign_org_attrs(data)
|
|
15
|
+
assign_workspace_attrs(data)
|
|
16
|
+
assign_session_attrs(data)
|
|
17
|
+
@permissions = Set.new(data['permissions'] || [])
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def can?(key)
|
|
22
|
+
@permissions.include?(key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def can_any?(*keys)
|
|
26
|
+
keys.any? { |key| @permissions.include?(key) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def can_all?(*keys)
|
|
30
|
+
keys.all? { |key| @permissions.include?(key) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cannot?(key)
|
|
34
|
+
!can?(key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authorize!(key)
|
|
38
|
+
raise Machina::Unauthorized, key unless can?(key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def authorize_any!(*keys)
|
|
42
|
+
raise Machina::Unauthorized, keys.join(', ') unless can_any?(*keys)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def authorize_all!(*keys)
|
|
46
|
+
keys.each { |key| authorize!(key) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Alias matching the product-side column name for consistency.
|
|
50
|
+
alias tenant_ref workspace_id
|
|
51
|
+
|
|
52
|
+
def permissions
|
|
53
|
+
@permissions.to_a.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def assign_user_attrs(data)
|
|
59
|
+
@user_id = data.dig('user', 'id')
|
|
60
|
+
@user_email = data.dig('user', 'email')
|
|
61
|
+
@user_name = data.dig('user', 'name')
|
|
62
|
+
@avatar_url = data.dig('user', 'avatar_url')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def assign_org_attrs(data)
|
|
66
|
+
@organization_id = data.dig('organization', 'id')
|
|
67
|
+
@org_name = data.dig('organization', 'name')
|
|
68
|
+
@org_personal = data.dig('organization', 'personal')
|
|
69
|
+
@org_role = data.dig('membership', 'policy_name') || data.dig('organization', 'role')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def assign_workspace_attrs(data)
|
|
73
|
+
@workspace_id = data.dig('workspace', 'id')
|
|
74
|
+
@workspace_name = data.dig('workspace', 'name')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def assign_session_attrs(data)
|
|
78
|
+
@session_id = data.dig('session', 'id') || data['session_id']
|
|
79
|
+
@type = data.dig('session', 'type')&.to_sym
|
|
80
|
+
raw_expires = data.dig('session', 'expires_at') || data['expires_at']
|
|
81
|
+
@expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
EMPTY = new.freeze # rubocop:disable Lint/UselessConstantScoping
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Holds configurable settings for the Machina gem such as service URLs,
|
|
5
|
+
# tokens, product identifiers, and cache options.
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :identity_service_url,
|
|
8
|
+
:service_token,
|
|
9
|
+
:product_id,
|
|
10
|
+
:product_slug,
|
|
11
|
+
:cache_store,
|
|
12
|
+
:cache_ttl,
|
|
13
|
+
:manifest
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@cache_ttl = 5.minutes
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Convenience methods mixed into Rails controllers for authentication,
|
|
5
|
+
# authorization, and session management.
|
|
6
|
+
module ControllerHelpers
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
helper_method :authorized, :logged_in? if respond_to?(:helper_method)
|
|
11
|
+
|
|
12
|
+
rescue_from Machina::Unauthorized do |error|
|
|
13
|
+
if request.format.json?
|
|
14
|
+
render json: { error: 'forbidden', permission: error.message }, status: :forbidden
|
|
15
|
+
else
|
|
16
|
+
redirect_to main_app.respond_to?(:root_path) ? main_app.root_path : '/',
|
|
17
|
+
alert: "You don't have permission to do that."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def authorized
|
|
23
|
+
current = defined?(::Current) ? ::Current : Machina::Current
|
|
24
|
+
current.authorized || Machina::Authorized::EMPTY
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def logged_in?
|
|
28
|
+
authorized.user_id.present?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def authenticate!
|
|
32
|
+
return if logged_in?
|
|
33
|
+
|
|
34
|
+
if request.format.json?
|
|
35
|
+
render json: { error: 'unauthorized' }, status: :unauthorized
|
|
36
|
+
else
|
|
37
|
+
redirect_to Machina.authorize_url(redirect_to: request.original_url), allow_other_host: true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Revokes the current session both locally and in the Console.
|
|
42
|
+
#
|
|
43
|
+
# Deletes the local cache entry, calls the Console to revoke server-side,
|
|
44
|
+
# and removes the session cookie. Errors from the Console call are silently
|
|
45
|
+
# swallowed so local logout always succeeds.
|
|
46
|
+
def logout!
|
|
47
|
+
token = cookies[:machina_session] || extract_bearer_token
|
|
48
|
+
if token.present?
|
|
49
|
+
Machina.cache.delete("machina:session:#{token}")
|
|
50
|
+
begin
|
|
51
|
+
Machina.identity_client.revoke_session(token)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# Best-effort: local logout succeeds even if Console is unreachable
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
cookies.delete(:machina_session)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def extract_bearer_token
|
|
62
|
+
header = request.headers['Authorization'].to_s
|
|
63
|
+
match = header.match(/\ABearer\s+(.+)\z/)
|
|
64
|
+
match && match[1]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Rails engine that mounts Machina routes, registers controller helpers,
|
|
5
|
+
# and triggers permission sync on application boot.
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Machina
|
|
8
|
+
|
|
9
|
+
rake_tasks do
|
|
10
|
+
load File.expand_path('tasks/sync.rake', __dir__)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer 'machina.controller_helpers' do
|
|
14
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
15
|
+
helper Machina::Engine.helpers if defined?(Machina::Engine.helpers)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Sync the permission manifest to the Console on boot when configured.
|
|
20
|
+
# Runs in a background thread so it doesn't block server startup.
|
|
21
|
+
# Skipped when manifest is not set.
|
|
22
|
+
config.after_initialize do
|
|
23
|
+
next if Machina.config.manifest.blank?
|
|
24
|
+
|
|
25
|
+
Thread.new do
|
|
26
|
+
Machina::PermissionSync.call!
|
|
27
|
+
Rails.logger.info('[machina] Permission manifest synced to Console')
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Rails.logger.warn("[machina] Permission sync failed on boot: #{e.message}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Base error class for all Machina-specific exceptions.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when an action is attempted without the required permission.
|
|
8
|
+
class Unauthorized < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a required configuration value is missing or invalid.
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# HTTP client for communicating with the Machina Console identity service.
|
|
5
|
+
# Handles session resolution, revocation, and permission syncing.
|
|
6
|
+
class IdentityClient
|
|
7
|
+
Response = Struct.new(:status, :body) do
|
|
8
|
+
def success?
|
|
9
|
+
status.between?(200, 299)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parsed
|
|
13
|
+
body.is_a?(Hash) ? body : JSON.parse(body)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(config: Machina.config, connection: nil)
|
|
18
|
+
@config = config
|
|
19
|
+
@connection = connection
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve_session(token)
|
|
23
|
+
post('/internal/v1/sessions/resolve', { token: })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def revoke_session(token)
|
|
27
|
+
post('/internal/v1/sessions/revoke', { token: })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sync_permissions(product_id:, permissions:, policies: [])
|
|
31
|
+
post("/internal/v1/products/#{product_id}/permissions_sync", {
|
|
32
|
+
permissions:,
|
|
33
|
+
policies:
|
|
34
|
+
})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :config
|
|
40
|
+
|
|
41
|
+
def post(path, payload)
|
|
42
|
+
ensure_configured!
|
|
43
|
+
|
|
44
|
+
response = connection.post(path) do |request|
|
|
45
|
+
request.headers['Authorization'] = "Bearer #{config.service_token}"
|
|
46
|
+
request.headers['Content-Type'] = 'application/json'
|
|
47
|
+
request.headers['Accept'] = 'application/json'
|
|
48
|
+
request.body = JSON.generate(payload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Response.new(status: response.status, body: response.body.presence || '{}')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def connection
|
|
55
|
+
@connection ||= Faraday.new(url: config.identity_service_url)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ensure_configured!
|
|
59
|
+
raise ConfigurationError, 'identity_service_url is required' if config.identity_service_url.blank?
|
|
60
|
+
raise ConfigurationError, 'service_token is required' if config.service_token.blank?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
module Middleware
|
|
5
|
+
# Rack middleware that extracts authentication tokens from incoming requests,
|
|
6
|
+
# resolves them against the identity service, and sets Current.authorized.
|
|
7
|
+
class Authentication
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
request = ActionDispatch::Request.new(env)
|
|
14
|
+
token = extract_token(request)
|
|
15
|
+
|
|
16
|
+
return @app.call(env) if token.blank?
|
|
17
|
+
|
|
18
|
+
session_data = resolve_session(token)
|
|
19
|
+
return unauthorized_response unless session_data
|
|
20
|
+
|
|
21
|
+
authorized = Machina::Authorized.new(session_data)
|
|
22
|
+
Machina::Current.authorized = authorized
|
|
23
|
+
::Current.authorized = authorized if defined?(::Current) && ::Current != Machina::Current
|
|
24
|
+
|
|
25
|
+
@app.call(env)
|
|
26
|
+
ensure
|
|
27
|
+
Machina::Current.reset
|
|
28
|
+
::Current.reset if defined?(::Current) && ::Current != Machina::Current
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def extract_token(request)
|
|
34
|
+
request.cookies['machina_session'] ||
|
|
35
|
+
extract_bearer(request) ||
|
|
36
|
+
request.headers['X-Api-Key'] ||
|
|
37
|
+
request.params['token']
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_bearer(request)
|
|
41
|
+
auth_header = request.headers['Authorization'].to_s
|
|
42
|
+
match = auth_header.match(/\ABearer\s+(.+)\z/)
|
|
43
|
+
match && match[1]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_session(token)
|
|
47
|
+
cached = Machina.cache.read(cache_key(token))
|
|
48
|
+
return cached if cached.present? && !(cached.is_a?(Hash) && cached[:stale])
|
|
49
|
+
|
|
50
|
+
fetch_from_identity_service(token)
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch_from_identity_service(token)
|
|
56
|
+
response = Machina.identity_client.resolve_session(token)
|
|
57
|
+
return nil unless response.success?
|
|
58
|
+
|
|
59
|
+
data = unwrap_payload(response.parsed)
|
|
60
|
+
Machina.cache.write(cache_key(token), data, expires_in: Machina.config.cache_ttl)
|
|
61
|
+
cache_workspace_ref(data)
|
|
62
|
+
data
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def unwrap_payload(payload)
|
|
66
|
+
payload.fetch('data', payload)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cache_workspace_ref(data)
|
|
70
|
+
workspace = data['workspace']
|
|
71
|
+
organization = data['organization']
|
|
72
|
+
return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
|
|
73
|
+
return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
|
|
74
|
+
|
|
75
|
+
ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
|
|
76
|
+
ref.organization_id = organization['id']
|
|
77
|
+
ref.name = workspace['name']
|
|
78
|
+
ref.cached_at = Time.current
|
|
79
|
+
ref.save!
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cache_key(token)
|
|
85
|
+
"machina:session:#{token}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unauthorized_response
|
|
89
|
+
body = JSON.generate(error: 'unauthorized')
|
|
90
|
+
[401, { 'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s }, [body]]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Reads the local permission manifest (machina.yml) and synchronises it
|
|
5
|
+
# with the Machina Console so the Console knows which permissions exist.
|
|
6
|
+
class PermissionSync
|
|
7
|
+
def self.call!
|
|
8
|
+
new.call!
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call!
|
|
12
|
+
manifest = YAML.load_file(Machina.config.manifest)
|
|
13
|
+
|
|
14
|
+
product_id = manifest['product_id'] || Machina.config.product_id
|
|
15
|
+
if product_id.blank?
|
|
16
|
+
raise Machina::ConfigurationError,
|
|
17
|
+
'product_id is required for permission sync (set in machina.yml or Machina.config)'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Machina.identity_client.sync_permissions(
|
|
21
|
+
product_id:,
|
|
22
|
+
permissions: manifest.fetch('permissions'),
|
|
23
|
+
policies: manifest.fetch('policies', []),
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|