devise_scim 0.1.11
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/AGENTS.md +124 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +348 -0
- data/Rakefile +21 -0
- data/app/controllers/devise_scim/application_controller.rb +69 -0
- data/app/controllers/devise_scim/groups_controller.rb +67 -0
- data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
- data/app/controllers/devise_scim/schemas_controller.rb +55 -0
- data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
- data/app/controllers/devise_scim/users_controller.rb +281 -0
- data/docs/contributing.md +163 -0
- data/docs/custom_adapter.md +456 -0
- data/docs/idp_setup.md +335 -0
- data/docs/multi_tenant.md +328 -0
- data/docs/testing.md +444 -0
- data/lib/devise_scim/auth/base_strategy.rb +16 -0
- data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
- data/lib/devise_scim/auth/token_strategy.rb +25 -0
- data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
- data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
- data/lib/devise_scim/configuration.rb +92 -0
- data/lib/devise_scim/engine.rb +15 -0
- data/lib/devise_scim/filter/arel_visitor.rb +77 -0
- data/lib/devise_scim/filter/parser.rb +190 -0
- data/lib/devise_scim/middleware/authenticator.rb +51 -0
- data/lib/devise_scim/minitest.rb +57 -0
- data/lib/devise_scim/models/scim_tenant.rb +14 -0
- data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
- data/lib/devise_scim/routing.rb +43 -0
- data/lib/devise_scim/rspec/factories.rb +17 -0
- data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
- data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
- data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
- data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
- data/lib/devise_scim/rspec.rb +7 -0
- data/lib/devise_scim/scim/error.rb +59 -0
- data/lib/devise_scim/scim/group.rb +66 -0
- data/lib/devise_scim/scim/list_response.rb +32 -0
- data/lib/devise_scim/scim/patch_operation.rb +55 -0
- data/lib/devise_scim/scim/user.rb +161 -0
- data/lib/devise_scim/scim_adapter.rb +84 -0
- data/lib/devise_scim/version.rb +5 -0
- data/lib/devise_scim.rb +48 -0
- data/lib/generators/devise_scim/adapter_generator.rb +17 -0
- data/lib/generators/devise_scim/install_generator.rb +117 -0
- data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
- data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
- data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
- data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
- data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
- data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
- data/sig/devise_scim.rbs +4 -0
- metadata +146 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module DeviseScim
|
|
6
|
+
module Scim
|
|
7
|
+
USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
8
|
+
ENTERPRISE_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
|
9
|
+
|
|
10
|
+
Name = Struct.new(:formatted, :given_name, :family_name, :middle_name,
|
|
11
|
+
:honorific_prefix, :honorific_suffix, keyword_init: true)
|
|
12
|
+
Email = Struct.new(:value, :type, :primary, keyword_init: true)
|
|
13
|
+
PhoneNumber = Struct.new(:value, :type, :primary, keyword_init: true)
|
|
14
|
+
Meta = Struct.new(:resource_type, :created, :last_modified, :version, :location,
|
|
15
|
+
keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# rubocop:disable Metrics/ClassLength
|
|
18
|
+
class User
|
|
19
|
+
SCHEMAS = [USER_SCHEMA].freeze
|
|
20
|
+
|
|
21
|
+
attr_accessor :id, :external_id, :user_name, :display_name, :nick_name,
|
|
22
|
+
:profile_url, :title, :user_type, :preferred_language,
|
|
23
|
+
:locale, :timezone, :active, :name, :emails, :phone_numbers,
|
|
24
|
+
:groups, :meta
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def from_h(hash)
|
|
28
|
+
user = new
|
|
29
|
+
assign_scalars(user, hash)
|
|
30
|
+
assign_name(user, hash)
|
|
31
|
+
user.emails = parse_emails(hash)
|
|
32
|
+
user.phone_numbers = parse_phones(hash)
|
|
33
|
+
user
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# rubocop:disable Metrics/AbcSize
|
|
39
|
+
def assign_scalars(user, hash)
|
|
40
|
+
user.id = hash["id"]
|
|
41
|
+
user.external_id = hash["externalId"]
|
|
42
|
+
user.user_name = hash["userName"]
|
|
43
|
+
user.display_name = hash["displayName"]
|
|
44
|
+
user.nick_name = hash["nickName"]
|
|
45
|
+
user.profile_url = hash["profileUrl"]
|
|
46
|
+
user.title = hash["title"]
|
|
47
|
+
user.user_type = hash["userType"]
|
|
48
|
+
user.preferred_language = hash["preferredLanguage"]
|
|
49
|
+
user.locale = hash["locale"]
|
|
50
|
+
user.timezone = hash["timezone"]
|
|
51
|
+
user.active = hash.key?("active") ? hash["active"] : true
|
|
52
|
+
end
|
|
53
|
+
# rubocop:enable Metrics/AbcSize
|
|
54
|
+
|
|
55
|
+
def assign_name(user, hash)
|
|
56
|
+
return unless (n = hash["name"])
|
|
57
|
+
|
|
58
|
+
user.name = Name.new(
|
|
59
|
+
formatted: n["formatted"],
|
|
60
|
+
given_name: n["givenName"],
|
|
61
|
+
family_name: n["familyName"],
|
|
62
|
+
middle_name: n["middleName"],
|
|
63
|
+
honorific_prefix: n["honorificPrefix"],
|
|
64
|
+
honorific_suffix: n["honorificSuffix"]
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_emails(hash)
|
|
69
|
+
Array(hash["emails"]).map do |entry|
|
|
70
|
+
Email.new(value: entry["value"], type: entry["type"], primary: entry["primary"])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_phones(hash)
|
|
75
|
+
Array(hash["phoneNumbers"]).map do |entry|
|
|
76
|
+
PhoneNumber.new(value: entry["value"], type: entry["type"], primary: entry["primary"])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
82
|
+
def to_h
|
|
83
|
+
h = base_hash.compact
|
|
84
|
+
h["schemas"] = SCHEMAS
|
|
85
|
+
h["name"] = serialize_name if name
|
|
86
|
+
h["emails"] = serialize_emails if emails&.any?
|
|
87
|
+
h["phoneNumbers"] = serialize_phones if phone_numbers&.any?
|
|
88
|
+
h["groups"] = groups if groups&.any?
|
|
89
|
+
h["meta"] = serialize_meta(meta, "User") if meta
|
|
90
|
+
h
|
|
91
|
+
end
|
|
92
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
93
|
+
|
|
94
|
+
def to_json(*)
|
|
95
|
+
to_h.to_json
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def primary_email
|
|
99
|
+
emails&.find(&:primary)&.value || user_name
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def base_hash
|
|
105
|
+
{
|
|
106
|
+
"schemas" => SCHEMAS,
|
|
107
|
+
"id" => id,
|
|
108
|
+
"externalId" => external_id,
|
|
109
|
+
"userName" => user_name,
|
|
110
|
+
"displayName" => display_name,
|
|
111
|
+
"nickName" => nick_name,
|
|
112
|
+
"profileUrl" => profile_url,
|
|
113
|
+
"title" => title,
|
|
114
|
+
"userType" => user_type,
|
|
115
|
+
"preferredLanguage" => preferred_language,
|
|
116
|
+
"locale" => locale,
|
|
117
|
+
"timezone" => timezone,
|
|
118
|
+
"active" => active
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def serialize_name
|
|
123
|
+
{
|
|
124
|
+
"formatted" => name.formatted,
|
|
125
|
+
"givenName" => name.given_name,
|
|
126
|
+
"familyName" => name.family_name,
|
|
127
|
+
"middleName" => name.middle_name,
|
|
128
|
+
"honorificPrefix" => name.honorific_prefix,
|
|
129
|
+
"honorificSuffix" => name.honorific_suffix
|
|
130
|
+
}.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def serialize_emails
|
|
134
|
+
emails.map do |email|
|
|
135
|
+
{ "value" => email.value, "type" => email.type, "primary" => email.primary }.compact
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def serialize_phones
|
|
140
|
+
phone_numbers.map do |phone|
|
|
141
|
+
{ "value" => phone.value, "type" => phone.type, "primary" => phone.primary }.compact
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def serialize_meta(met, resource_type)
|
|
146
|
+
{
|
|
147
|
+
"resourceType" => met.resource_type || resource_type,
|
|
148
|
+
"created" => iso8601_or_raw(met.created),
|
|
149
|
+
"lastModified" => iso8601_or_raw(met.last_modified),
|
|
150
|
+
"version" => met.version,
|
|
151
|
+
"location" => met.location
|
|
152
|
+
}.compact
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def iso8601_or_raw(value)
|
|
156
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
# rubocop:enable Metrics/ClassLength
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class ScimAdapter
|
|
5
|
+
attr_reader :record, :scim_user, :scim_group, :tenant
|
|
6
|
+
|
|
7
|
+
def initialize(record, scim_object, tenant: nil)
|
|
8
|
+
@record = record
|
|
9
|
+
@scim_user = scim_object if scim_object.is_a?(Scim::User)
|
|
10
|
+
@scim_group = scim_object if scim_object.is_a?(Scim::Group)
|
|
11
|
+
@tenant = tenant
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def attributes_for_create
|
|
15
|
+
base_user_attributes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def attributes_for_update
|
|
19
|
+
base_user_attributes
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def after_provision; end
|
|
23
|
+
def after_deprovision; end
|
|
24
|
+
|
|
25
|
+
def handle_group_create; end
|
|
26
|
+
def handle_group_update; end
|
|
27
|
+
def handle_group_destroy; end
|
|
28
|
+
|
|
29
|
+
def to_scim
|
|
30
|
+
scim = Scim::User.new
|
|
31
|
+
scim.id = record.id.to_s
|
|
32
|
+
scim.user_name = record.email
|
|
33
|
+
scim.active = resolve_active
|
|
34
|
+
scim.emails = [Scim::Email.new(value: record.email, type: "work", primary: true)]
|
|
35
|
+
scim.name = build_name
|
|
36
|
+
scim.meta = build_meta("User")
|
|
37
|
+
scim
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def group_to_scim
|
|
41
|
+
raise NotImplementedError, "#{self.class}#group_to_scim must be implemented when enable_groups is true"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def base_user_attributes
|
|
47
|
+
attrs = { email: scim_user.user_name || scim_user.primary_email }
|
|
48
|
+
attrs[:first_name] = scim_user.name&.given_name if column?(:first_name)
|
|
49
|
+
attrs[:last_name] = scim_user.name&.family_name if column?(:last_name)
|
|
50
|
+
attrs
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def column?(name)
|
|
54
|
+
record.class.respond_to?(:column_names) &&
|
|
55
|
+
record.class.column_names.include?(name.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_active
|
|
59
|
+
if column?(:scim_active)
|
|
60
|
+
record.scim_active
|
|
61
|
+
elsif column?(:deleted_at)
|
|
62
|
+
record.deleted_at.nil?
|
|
63
|
+
else
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_name
|
|
69
|
+
given = column?(:first_name) ? record.first_name : nil
|
|
70
|
+
family = column?(:last_name) ? record.last_name : nil
|
|
71
|
+
return nil unless given || family
|
|
72
|
+
|
|
73
|
+
Scim::Name.new(given_name: given, family_name: family)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_meta(resource_type)
|
|
77
|
+
Scim::Meta.new(
|
|
78
|
+
resource_type: resource_type,
|
|
79
|
+
created: record.created_at,
|
|
80
|
+
last_modified: record.updated_at
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/devise_scim.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "devise_scim/version"
|
|
4
|
+
require_relative "devise_scim/configuration"
|
|
5
|
+
|
|
6
|
+
module DeviseScim
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
class NotFound < Error; end
|
|
10
|
+
class Conflict < Error; end
|
|
11
|
+
class InvalidFilter < Error; end
|
|
12
|
+
class Unauthorized < Error; end
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def configure
|
|
16
|
+
yield configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configuration
|
|
20
|
+
@configuration ||= Configuration.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_configuration!
|
|
24
|
+
@configuration = Configuration.new
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if defined?(Rails)
|
|
30
|
+
require_relative "devise_scim/concerns/scim_tenant"
|
|
31
|
+
require_relative "devise_scim/models/scim_tenant"
|
|
32
|
+
require_relative "devise_scim/models/scim_tenant_user"
|
|
33
|
+
require_relative "devise_scim/scim/user"
|
|
34
|
+
require_relative "devise_scim/scim/group"
|
|
35
|
+
require_relative "devise_scim/scim/list_response"
|
|
36
|
+
require_relative "devise_scim/scim/error"
|
|
37
|
+
require_relative "devise_scim/scim/patch_operation"
|
|
38
|
+
require_relative "devise_scim/scim_adapter"
|
|
39
|
+
require_relative "devise_scim/concerns/scim_group_identifiable"
|
|
40
|
+
require_relative "devise_scim/filter/parser"
|
|
41
|
+
require_relative "devise_scim/filter/arel_visitor"
|
|
42
|
+
require_relative "devise_scim/auth/base_strategy"
|
|
43
|
+
require_relative "devise_scim/auth/token_strategy"
|
|
44
|
+
require_relative "devise_scim/auth/oauth_strategy"
|
|
45
|
+
require_relative "devise_scim/middleware/authenticator"
|
|
46
|
+
require_relative "devise_scim/routing"
|
|
47
|
+
require_relative "devise_scim/engine"
|
|
48
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module DeviseScim
|
|
6
|
+
module Generators
|
|
7
|
+
class AdapterGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates a pre-filled ScimAdapter in app/scim/application_scim_adapter.rb"
|
|
11
|
+
|
|
12
|
+
def copy_adapter
|
|
13
|
+
template "application_scim_adapter.rb.tt", "app/scim/application_scim_adapter.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module DeviseScim
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
argument :model_name, type: :string, default: "User",
|
|
14
|
+
desc: "Devise model to add SCIM fields to (e.g. User)"
|
|
15
|
+
|
|
16
|
+
class_option :multi_tenant, type: :boolean, default: false,
|
|
17
|
+
desc: "Generate multi-tenant migrations"
|
|
18
|
+
class_option :oauth, type: :boolean, default: false,
|
|
19
|
+
desc: "Configure OAuth 2.0 client-credentials auth"
|
|
20
|
+
class_option :tenant_model, type: :string, default: nil,
|
|
21
|
+
desc: "Existing model to use as the SCIM tenant (e.g. Org). " \
|
|
22
|
+
"Omit to use the built-in DeviseScim::ScimTenant."
|
|
23
|
+
|
|
24
|
+
def self.next_migration_number(dirname)
|
|
25
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def preflight_check
|
|
29
|
+
return unless needs_doorkeeper?
|
|
30
|
+
|
|
31
|
+
unless doorkeeper_in_gemfile?
|
|
32
|
+
say_status :error, "Doorkeeper gem not found in Gemfile.", :red
|
|
33
|
+
say " Add `gem 'doorkeeper', '~> 5.6'` to your Gemfile and run `bundle install`.", :red
|
|
34
|
+
raise Thor::Error, "Aborting: Doorkeeper required for #{doorkeeper_reason}."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return if doorkeeper_installed?
|
|
38
|
+
|
|
39
|
+
unless yes?("Doorkeeper not yet installed. Run `rails g doorkeeper:install` now?")
|
|
40
|
+
raise Thor::Error, "Aborting. Run `rails g doorkeeper:install` before proceeding."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
generate "doorkeeper:install"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def copy_user_migration
|
|
47
|
+
migration_template(
|
|
48
|
+
"add_scim_to_users.rb.tt",
|
|
49
|
+
"db/migrate/add_scim_to_#{table_name}.rb"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def copy_tenant_migrations
|
|
54
|
+
return unless options[:multi_tenant]
|
|
55
|
+
|
|
56
|
+
if options[:tenant_model]
|
|
57
|
+
migration_template "add_scim_to_tenant.rb.tt",
|
|
58
|
+
"db/migrate/add_scim_to_#{tenant_table_name}.rb"
|
|
59
|
+
else
|
|
60
|
+
migration_template "create_scim_tenants.rb.tt", "db/migrate/create_scim_tenants.rb"
|
|
61
|
+
end
|
|
62
|
+
migration_template "create_scim_tenant_users.rb.tt", "db/migrate/create_scim_tenant_users.rb"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def copy_initializer
|
|
66
|
+
template "devise_scim.rb.tt", "config/initializers/devise_scim.rb"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def needs_doorkeeper?
|
|
72
|
+
options[:oauth] || options[:multi_tenant]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def doorkeeper_reason
|
|
76
|
+
options[:multi_tenant] ? "--multi-tenant mode" : "--oauth auth"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def doorkeeper_in_gemfile?
|
|
80
|
+
gemfile = File.join(destination_root, "Gemfile")
|
|
81
|
+
File.exist?(gemfile) && File.read(gemfile).include?("doorkeeper")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def doorkeeper_installed?
|
|
85
|
+
Dir[File.join(destination_root, "db/migrate/*doorkeeper*")].any? ||
|
|
86
|
+
Dir[File.join(destination_root, "db/migrate/*create_doorkeeper_tables*")].any?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def table_name
|
|
90
|
+
model_name.underscore.pluralize
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tenant_table_name
|
|
94
|
+
options[:tenant_model] ? options[:tenant_model].underscore.pluralize : "scim_tenants"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def tenant_ref_name
|
|
98
|
+
options[:tenant_model] ? options[:tenant_model].underscore : "scim_tenant"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def tenant_fk_column
|
|
102
|
+
options[:tenant_model] ? "#{options[:tenant_model].underscore}_id" : "scim_tenant_id"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def migration_version
|
|
106
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def scim_raw_type
|
|
110
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
111
|
+
adapter.include?("postgresql") ? "jsonb" : "text"
|
|
112
|
+
rescue StandardError
|
|
113
|
+
"text"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddScimTo<%= options[:tenant_model].camelize.pluralize %> < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
unless column_exists?(:<%= tenant_table_name %>, :token_digest)
|
|
6
|
+
add_column :<%= tenant_table_name %>, :token_digest, :string
|
|
7
|
+
end
|
|
8
|
+
unless column_exists?(:<%= tenant_table_name %>, :auth_method)
|
|
9
|
+
add_column :<%= tenant_table_name %>, :auth_method, :string, null: false, default: "token"
|
|
10
|
+
end
|
|
11
|
+
unless column_exists?(:<%= tenant_table_name %>, :doorkeeper_application_id)
|
|
12
|
+
add_column :<%= tenant_table_name %>, :doorkeeper_application_id, :bigint
|
|
13
|
+
add_foreign_key :<%= tenant_table_name %>, :oauth_applications,
|
|
14
|
+
column: :doorkeeper_application_id, on_delete: :nullify
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddScimTo<%= model_name.camelize.pluralize %> < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
<%- unless options[:multi_tenant] -%>
|
|
6
|
+
add_column :<%= table_name %>, :scim_uid, :string
|
|
7
|
+
add_index :<%= table_name %>, :scim_uid, unique: true
|
|
8
|
+
<%- end -%>
|
|
9
|
+
add_column :<%= table_name %>, :scim_provisioned, :boolean, default: false, null: false
|
|
10
|
+
add_column :<%= table_name %>, :scim_active, :boolean, default: true, null: false
|
|
11
|
+
add_column :<%= table_name %>, :scim_source, :string
|
|
12
|
+
add_column :<%= table_name %>, :scim_deprovisioned_at, :datetime
|
|
13
|
+
add_column :<%= table_name %>, :scim_raw, :<%= scim_raw_type %>
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ApplicationScimAdapter < DeviseScim::ScimAdapter
|
|
4
|
+
# Override to customize attributes assigned on create.
|
|
5
|
+
# Must return a Hash of AR attribute names to values.
|
|
6
|
+
#
|
|
7
|
+
# def attributes_for_create
|
|
8
|
+
# super.merge(role: "user")
|
|
9
|
+
# end
|
|
10
|
+
|
|
11
|
+
# Override to customize attributes assigned on update.
|
|
12
|
+
#
|
|
13
|
+
# def attributes_for_update
|
|
14
|
+
# super
|
|
15
|
+
# end
|
|
16
|
+
|
|
17
|
+
# Lifecycle hooks — called after provision/deprovision completes.
|
|
18
|
+
# def after_provision; end
|
|
19
|
+
# def after_deprovision; end
|
|
20
|
+
|
|
21
|
+
# Implement to handle SCIM Group operations.
|
|
22
|
+
# def handle_group_create; end
|
|
23
|
+
# def handle_group_update; end
|
|
24
|
+
# def handle_group_destroy; end
|
|
25
|
+
|
|
26
|
+
# Required when enable_groups is true. Serialize a group AR record → Scim::Group.
|
|
27
|
+
#
|
|
28
|
+
# def group_to_scim
|
|
29
|
+
# DeviseScim::Scim::Group.new.tap do |g|
|
|
30
|
+
# g.id = record.id.to_s
|
|
31
|
+
# g.display_name = record.name
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateScimTenantUsers < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :scim_tenant_users do |t|
|
|
6
|
+
t.references :<%= tenant_ref_name %>, null: false,
|
|
7
|
+
foreign_key: { to_table: :<%= tenant_table_name %> }
|
|
8
|
+
t.bigint :user_id, null: false
|
|
9
|
+
t.string :scim_uid
|
|
10
|
+
t.datetime :provisioned_at
|
|
11
|
+
t.datetime :scim_claimed_at
|
|
12
|
+
t.boolean :active, null: false, default: true
|
|
13
|
+
t.<%= scim_raw_type %> :scim_raw
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :scim_tenant_users, [:<%= tenant_fk_column %>, :user_id], unique: true
|
|
18
|
+
add_index :scim_tenant_users, [:<%= tenant_fk_column %>, :scim_uid], unique: true,
|
|
19
|
+
where: "scim_uid IS NOT NULL"
|
|
20
|
+
add_foreign_key :scim_tenant_users, :<%= table_name %>, column: :user_id
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateScimTenants < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :scim_tenants do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :auth_method, null: false, default: "token"
|
|
8
|
+
t.string :token_digest
|
|
9
|
+
t.bigint :doorkeeper_application_id
|
|
10
|
+
t.boolean :active, null: false, default: true
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_foreign_key :scim_tenants, :oauth_applications,
|
|
15
|
+
column: :doorkeeper_application_id,
|
|
16
|
+
on_delete: :nullify
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
DeviseScim.configure do |config|
|
|
4
|
+
# Mount path for all SCIM 2.0 endpoints.
|
|
5
|
+
config.route_prefix = "/scim/v2"
|
|
6
|
+
|
|
7
|
+
# Tenancy mode. :single = one IdP; :multi = per-tenant credentials and scoping.
|
|
8
|
+
config.tenancy = :<%= options[:multi_tenant] ? "multi" : "single" %>
|
|
9
|
+
|
|
10
|
+
# Devise model that represents provisioned users.
|
|
11
|
+
config.devise_model = "<%= model_name %>"
|
|
12
|
+
|
|
13
|
+
# Expose /Groups endpoints. Implement handle_group_* in your ScimAdapter when true.
|
|
14
|
+
config.enable_groups = false
|
|
15
|
+
|
|
16
|
+
# true = destroy record on DELETE; false = set scim_active=false only.
|
|
17
|
+
config.soft_delete = true
|
|
18
|
+
|
|
19
|
+
# Behaviour when a DELETE targets a user not originally provisioned via SCIM.
|
|
20
|
+
# false = 200 OK, no action (default); true = deprovision anyway; :error = 409 Conflict.
|
|
21
|
+
config.deprovision_manual_users = false
|
|
22
|
+
|
|
23
|
+
# Adapter class that maps SCIM attributes to your model.
|
|
24
|
+
# Run `rails g devise_scim:adapter` to generate a pre-filled starting point.
|
|
25
|
+
config.adapter = nil # replace with ApplicationScimAdapter after running the adapter generator
|
|
26
|
+
|
|
27
|
+
# ── Single-tenant auth ────────────────────────────────────────────────────────
|
|
28
|
+
# Ignored in multi-tenant mode (credentials live on each ScimTenant record).
|
|
29
|
+
|
|
30
|
+
# :token = Authorization: Bearer <token>; :oauth = Doorkeeper client credentials.
|
|
31
|
+
config.auth_method = :token
|
|
32
|
+
|
|
33
|
+
# Bearer token (plain-text). Store in an env var — never commit to source control.
|
|
34
|
+
config.token = ENV.fetch("SCIM_BEARER_TOKEN", nil)
|
|
35
|
+
|
|
36
|
+
# OAuth 2.0 client credentials. Requires the doorkeeper gem.
|
|
37
|
+
config.oauth_client_id = ENV.fetch("SCIM_CLIENT_ID", nil)
|
|
38
|
+
config.oauth_client_secret = ENV.fetch("SCIM_CLIENT_SECRET", nil)
|
|
39
|
+
|
|
40
|
+
# ── Multi-tenant options ──────────────────────────────────────────────────────
|
|
41
|
+
# Ignored in single-tenant mode.
|
|
42
|
+
<% if options[:tenant_model] %>
|
|
43
|
+
# AR model used as the SCIM tenant. Defaults to DeviseScim::ScimTenant.
|
|
44
|
+
config.tenant_model = "<%= options[:tenant_model] %>"
|
|
45
|
+
<% end %>
|
|
46
|
+
# :multiple = a user may belong to many tenants (default).
|
|
47
|
+
# :one_to_one = a user may belong to at most one tenant.
|
|
48
|
+
config.user_exclusivity = :multiple
|
|
49
|
+
|
|
50
|
+
# Behaviour when :one_to_one and a POST /Users matches a user in another tenant.
|
|
51
|
+
# :error = 409 Conflict (default); :reassign = move user to new tenant.
|
|
52
|
+
config.exclusivity_conflict = :error
|
|
53
|
+
end
|
data/sig/devise_scim.rbs
ADDED