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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/README.md +54 -0
  4. data/app/controllers/machina/webhooks_controller.rb +14 -0
  5. data/config/routes.rb +5 -0
  6. data/lib/generators/machina/install_generator.rb +32 -0
  7. data/lib/generators/machina/templates/create_machina_workspace_refs.rb.tt +13 -0
  8. data/lib/generators/machina/templates/machina.rb.tt +9 -0
  9. data/lib/generators/machina/templates/machina.yml.tt +8 -0
  10. data/lib/machina/authorized.rb +86 -0
  11. data/lib/machina/configuration.rb +19 -0
  12. data/lib/machina/controller_helpers.rb +67 -0
  13. data/lib/machina/current.rb +8 -0
  14. data/lib/machina/engine.rb +33 -0
  15. data/lib/machina/errors.rb +12 -0
  16. data/lib/machina/identity_client.rb +63 -0
  17. data/lib/machina/middleware/authentication.rb +94 -0
  18. data/lib/machina/permission_sync.rb +27 -0
  19. data/lib/machina/tasks/sync.rake +9 -0
  20. data/lib/machina/test_helpers.rb +134 -0
  21. data/lib/machina/version.rb +5 -0
  22. data/lib/machina/webhook_receiver.rb +83 -0
  23. data/lib/machina/workspace_ref.rb +9 -0
  24. data/lib/machina/workspace_scoped.rb +20 -0
  25. data/lib/machina.rb +68 -0
  26. data/machina-auth.gemspec +43 -0
  27. data/spec/contracts/permissions_sync_contract_spec.rb +59 -0
  28. data/spec/contracts/session_resolution_contract_spec.rb +86 -0
  29. data/spec/contracts/session_revocation_contract_spec.rb +52 -0
  30. data/spec/contracts/webhook_signature_contract_spec.rb +67 -0
  31. data/spec/dummy/app/models/application_record.rb +5 -0
  32. data/spec/dummy/app/models/current.rb +4 -0
  33. data/spec/dummy/config/application.rb +17 -0
  34. data/spec/dummy/config/boot.rb +5 -0
  35. data/spec/dummy/config/database.yml +3 -0
  36. data/spec/dummy/config/environment.rb +5 -0
  37. data/spec/dummy/config/environments/test.rb +11 -0
  38. data/spec/dummy/config/routes.rb +7 -0
  39. data/spec/dummy/db/schema.rb +11 -0
  40. data/spec/fixtures/machina.yml +9 -0
  41. data/spec/machina/authorized_spec.rb +161 -0
  42. data/spec/machina/configuration_spec.rb +15 -0
  43. data/spec/machina/controller_helpers_spec.rb +100 -0
  44. data/spec/machina/identity_client_spec.rb +69 -0
  45. data/spec/machina/middleware/authentication_spec.rb +71 -0
  46. data/spec/machina/permission_sync_spec.rb +29 -0
  47. data/spec/machina/test_helpers_spec.rb +84 -0
  48. data/spec/machina/webhook_receiver_spec.rb +93 -0
  49. data/spec/machina/workspace_scoped_spec.rb +34 -0
  50. data/spec/rails_helper.rb +34 -0
  51. data/spec/requests/machina/webhooks_spec.rb +31 -0
  52. data/spec/spec_helper.rb +3 -0
  53. data/spec/support/console_schema.rb +73 -0
  54. data/spec/support/mock_responses.rb +73 -0
  55. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Machina::Engine.routes.draw do
4
+ post 'webhooks', to: 'webhooks#create'
5
+ end
@@ -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,8 @@
1
+ product_id: # UUID from Console (required for permission sync)
2
+
3
+ permissions: []
4
+
5
+ policies:
6
+ - name: Admin
7
+ api_name: admin
8
+ permissions: []
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Machina
4
+ # Thread-safe per-request store for the currently authenticated Authorized object.
5
+ class Current < ActiveSupport::CurrentAttributes
6
+ attribute :authorized
7
+ end
8
+ 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :machina do
4
+ desc 'Sync permission manifest to Console'
5
+ task sync_permissions: :environment do
6
+ Machina::PermissionSync.call!
7
+ puts 'Permissions synced successfully.'
8
+ end
9
+ end