Skip to content

Commit 0a3eb63

Browse files
committed
[1.1.6] Full support of multi-tenancy Organizations
1 parent cf4f1bf commit 0a3eb63

13 files changed

+1006
-3
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.6] - 2026-01-05
9+
10+
* [Feature] Support for Organizations (Multi-tenancy):
11+
* **Organization Management**:
12+
* Supported operations: `create!`, `update`, `get`, `delete`, `list`, and `count`.
13+
* Supported searching and filtering via `exact`, `query`, and `search` parameters.
14+
* **Member Management**:
15+
* Added ability to list organization members with pagination and filtering (`members`).
16+
* Added `members_count` to retrieve the total number of members.
17+
* Added `get_member`, `add_member` (by user ID), and `delete_member`.
18+
* Added helper to find all organizations associated with a specific user: `associated_with_member`.
19+
* **Invitations**:
20+
* Added `invite_user`: Invites a new user via email/name.
21+
* Added `invite_existing_user`: Invites an existing Keycloak user to the organization by ID.
22+
* **Identity Provider (IdP) Linking**:
23+
* Added methods to manage IdPs linked to an organization: `identity_providers`, `get_identity_provider`, `add_identity_provider`, and `delete_identity_provider`.
24+
825
## [1.1.5] - 2026-01-05
926

1027
* [Feature] Added the ability to list credentials for a given user.

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
keycloak-admin (1.1.5)
4+
keycloak-admin (1.1.6)
55
http-cookie (~> 1.0, >= 1.0.3)
66
rest-client (~> 2.0)
77

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ This gem *does not* require Rails.
1212
For example, using `bundle`, add this line to your Gemfile.
1313

1414
```ruby
15-
gem "keycloak-admin", "1.1.5"
15+
gem "keycloak-admin", "1.1.6"
1616
```
1717

1818
## Login
@@ -135,6 +135,11 @@ All options have a default value. However, all of them can be changed in your in
135135
* Execute actions emails
136136
* Send forgot passsword mail
137137
* Client Authorization, create, update, get, delete Resource, Scope, Policy, Permission, Policy Enforcer
138+
* Get list of organizations, create/update/get/delete an organization
139+
* Get list of members of an organization, add/remove members
140+
* Invite new or existing users to an organization
141+
* List, add, and remove Identity Providers for an organization
142+
* Get list of organizations associated with a specific user
138143

139144
### Get an access token
140145

@@ -252,6 +257,12 @@ user_id = "95985b21-d884-4bbd-b852-cb8cd365afc2"
252257
KeycloakAdmin.realm("a_realm").users.get_redirect_impersonation(user_id)
253258
```
254259

260+
### List all the organizations of a realm
261+
262+
```ruby
263+
KeycloakAdmin.realm("a_realm").organizations.list
264+
```
265+
255266
### Exchange a configurable token
256267

257268
*Requires your Keycloak server to have deployed the Custom REST API `configurable-token`* (https://github.com/looorent/keycloak-configurable-token-api)

lib/keycloak-admin.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require_relative "keycloak-admin/client/client_role_mappings_client"
88
require_relative "keycloak-admin/client/group_client"
99
require_relative "keycloak-admin/client/realm_client"
10+
require_relative "keycloak-admin/client/organization_client"
1011
require_relative "keycloak-admin/client/role_client"
1112
require_relative "keycloak-admin/client/role_mapper_client"
1213
require_relative "keycloak-admin/client/token_client"
@@ -27,10 +28,13 @@
2728
require_relative "keycloak-admin/representation/impersonation_redirection_representation"
2829
require_relative "keycloak-admin/representation/impersonation_representation"
2930
require_relative "keycloak-admin/representation/credential_representation"
31+
require_relative "keycloak-admin/representation/organization_domain_representation"
32+
require_relative "keycloak-admin/representation/organization_representation"
3033
require_relative "keycloak-admin/representation/realm_representation"
3134
require_relative "keycloak-admin/representation/role_representation"
3235
require_relative "keycloak-admin/representation/federated_identity_representation"
3336
require_relative "keycloak-admin/representation/user_representation"
37+
require_relative "keycloak-admin/representation/member_representation"
3438
require_relative "keycloak-admin/representation/identity_provider_mapper_representation"
3539
require_relative "keycloak-admin/representation/identity_provider_representation"
3640
require_relative "keycloak-admin/representation/attack_detection_representation"
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
module KeycloakAdmin
2+
class OrganizationClient < Client
3+
def initialize(configuration, realm_client)
4+
super(configuration)
5+
raise ArgumentError.new("realm must be defined") unless realm_client.name_defined?
6+
@realm_client = realm_client
7+
end
8+
9+
# This endpoint does not return members
10+
def list(brief_representation=true, exact=nil, first=nil, max=nil, query=nil, search=nil)
11+
response = execute_http do
12+
RestClient::Resource.new(organizations_url_with_parameters(brief_representation, exact, first, max, query, search), @configuration.rest_client_options).get(headers)
13+
end
14+
JSON.parse(response).map { |organization_as_hash| OrganizationRepresentation.from_hash(organization_as_hash) }
15+
end
16+
17+
def count(exact=nil, query=nil, search=nil)
18+
response = execute_http do
19+
RestClient::Resource.new(count_url(exact, query, search), @configuration.rest_client_options).get(headers)
20+
end
21+
response.to_i
22+
end
23+
24+
def delete(organization_id)
25+
execute_http do
26+
RestClient::Resource.new(organization_url(organization_id), @configuration.rest_client_options).delete(headers)
27+
end
28+
true
29+
end
30+
31+
def update(organization_representation)
32+
execute_http do
33+
RestClient::Resource.new(organization_url(organization_representation.id), @configuration.rest_client_options).put(
34+
create_payload(organization_representation), headers
35+
)
36+
end
37+
38+
get(organization_representation.id)
39+
end
40+
41+
def create!(name, alias_name, enabled, description, redirect_url=nil, domains=[], attributes={})
42+
save(build(name, alias_name, enabled, description, redirect_url, domains, attributes))
43+
end
44+
45+
# This operation does not associate members and identity providers
46+
def save(organization_representation)
47+
execute_http do
48+
RestClient::Resource.new(organizations_url, @configuration.rest_client_options).post(
49+
create_payload(organization_representation), headers
50+
)
51+
end
52+
true
53+
end
54+
55+
def get(organization_id)
56+
response = execute_http do
57+
RestClient::Resource.new(organization_url(organization_id), @configuration.rest_client_options).get(headers)
58+
end
59+
OrganizationRepresentation.from_hash(JSON.parse(response))
60+
end
61+
62+
def identity_providers(organization_id)
63+
response = execute_http do
64+
RestClient::Resource.new(identity_providers_url(organization_id), @configuration.rest_client_options).get(headers)
65+
end
66+
JSON.parse(response).map { |idp_as_hash| IdentityProviderRepresentation.from_hash(idp_as_hash) }
67+
end
68+
69+
def get_identity_provider(organization_id, identity_provider_alias)
70+
raise ArgumentError.new("identity_provider_alias must be defined") if identity_provider_alias.nil?
71+
response = execute_http do
72+
RestClient::Resource.new("#{identity_providers_url(organization_id)}/#{identity_provider_alias}", @configuration.rest_client_options).get(headers)
73+
end
74+
IdentityProviderRepresentation.from_hash(JSON.parse(response))
75+
end
76+
77+
def add_identity_provider(organization_id, identity_provider_alias)
78+
raise ArgumentError.new("identity_provider_alias must be defined") if identity_provider_alias.nil?
79+
execute_http do
80+
RestClient::Resource.new(identity_providers_url(organization_id), @configuration.rest_client_options).post(identity_provider_alias, headers)
81+
end
82+
true
83+
end
84+
85+
def delete_identity_provider(organization_id, identity_provider_alias)
86+
execute_http do
87+
RestClient::Resource.new(identity_provider_url(organization_id, identity_provider_alias), @configuration.rest_client_options).delete(headers)
88+
end
89+
true
90+
end
91+
92+
def members_count(organization_id)
93+
response = execute_http do
94+
RestClient::Resource.new(members_count_url(organization_id), @configuration.rest_client_options).get(headers)
95+
end
96+
response.to_i
97+
end
98+
99+
def members(organization_id, exact=nil, first=nil, max=nil, membership_type=nil, search=nil)
100+
response = execute_http do
101+
RestClient::Resource.new(members_url_with_query_parameters(organization_id, exact, first, max, membership_type, search), @configuration.rest_client_options).get(headers)
102+
end
103+
JSON.parse(response).map { |member_as_hash| MemberRepresentation.from_hash(member_as_hash) }
104+
end
105+
106+
def invite_existing_user(organization_id, user_id)
107+
raise ArgumentError.new("user_id must be defined") if user_id.nil?
108+
execute_http do
109+
RestClient::Resource.new(invite_existing_user_url(organization_id), @configuration.rest_client_options).post({id: user_id}, headers.merge(content_type: "application/x-www-form-urlencoded"))
110+
end
111+
true
112+
end
113+
114+
def invite_user(organization_id, email, first_name, last_name)
115+
execute_http do
116+
RestClient::Resource.new(invite_user_url(organization_id), @configuration.rest_client_options).post({
117+
email: email,
118+
firstName: first_name,
119+
lastName: last_name
120+
}, headers.merge(content_type: "application/x-www-form-urlencoded"))
121+
end
122+
true
123+
end
124+
125+
def add_member(organization_id, user_id)
126+
raise ArgumentError.new("user_id must be defined") if user_id.nil?
127+
execute_http do
128+
RestClient::Resource.new(members_url(organization_id), @configuration.rest_client_options).post(user_id, headers)
129+
end
130+
true
131+
end
132+
133+
def delete_member(organization_id, member_id)
134+
execute_http do
135+
RestClient::Resource.new(member_url(organization_id, member_id), @configuration.rest_client_options).delete(headers)
136+
end
137+
true
138+
end
139+
140+
def get_member(organization_id, member_id)
141+
response = execute_http do
142+
RestClient::Resource.new(member_url(organization_id, member_id), @configuration.rest_client_options).get(headers)
143+
end
144+
MemberRepresentation.from_hash(JSON.parse(response))
145+
end
146+
147+
def associated_with_member(member_id, brief_representation=true)
148+
response = execute_http do
149+
RestClient::Resource.new(associated_with_member_url(member_id, brief_representation), @configuration.rest_client_options).get(headers)
150+
end
151+
JSON.parse(response).map { |organization_as_hash| OrganizationRepresentation.from_hash(organization_as_hash) }
152+
end
153+
154+
def organizations_url
155+
"#{@realm_client.realm_admin_url}/organizations"
156+
end
157+
158+
def organization_url(organization_id)
159+
raise ArgumentError.new("organization_id must be defined") if organization_id.nil?
160+
"#{organizations_url}/#{organization_id}"
161+
end
162+
163+
def identity_providers_url(organization_id)
164+
"#{organization_url(organization_id)}/identity-providers"
165+
end
166+
167+
def identity_provider_url(organization_id, identity_provider_alias)
168+
raise ArgumentError.new("identity_provider_alias must be defined") if identity_provider_alias.nil?
169+
"#{identity_providers_url(organization_id)}/#{identity_provider_alias}"
170+
end
171+
172+
def count_url(exact, query, search)
173+
query_parameters = {exact: exact, q: query, search: search}.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
174+
"#{organizations_url}/count?#{query_parameters}"
175+
end
176+
177+
def organizations_url_with_parameters(brief_representation, exact, first, max, query, search)
178+
query_parameters = {
179+
briefRepresentation: brief_representation,
180+
exact: exact,
181+
first: first,
182+
max: max,
183+
q: query,
184+
search: search
185+
}.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
186+
"#{organizations_url}?#{query_parameters}"
187+
end
188+
189+
def associated_with_member_url(member_id, brief_representation=true)
190+
"#{organizations_url}/members/#{member_id}/organizations?briefRepresentation=#{brief_representation}"
191+
end
192+
193+
def members_count_url(organization_id)
194+
"#{organization_url(organization_id)}/members/count"
195+
end
196+
197+
def member_url(organization_id, member_id)
198+
raise ArgumentError.new("member_id must be defined") if member_id.nil?
199+
"#{organization_url(organization_id)}/members/#{member_id}"
200+
end
201+
202+
def invite_existing_user_url(organization_id)
203+
"#{organization_url(organization_id)}/members/invite-existing-user"
204+
end
205+
206+
def invite_user_url(organization_id)
207+
"#{organization_url(organization_id)}/members/invite-user"
208+
end
209+
210+
def members_url(organization_id)
211+
"#{organization_url(organization_id)}/members"
212+
end
213+
214+
def members_url_with_query_parameters(organization_id, exact, first, max, membership_type, search)
215+
query_parameters = {
216+
exact: exact,
217+
first: first,
218+
max: max,
219+
membershipType: membership_type,
220+
search: search
221+
}.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
222+
"#{organization_url(organization_id)}/members?#{query_parameters}"
223+
end
224+
225+
def build(name, alias_name, enabled, description, redirect_url=nil, domains=[], attributes={})
226+
unless domains.is_a?(Array)
227+
raise ArgumentError.new("domains must be an Array, got #{new_domains.class}")
228+
end
229+
230+
unless domains.all? { |domain| domain.is_a?(KeycloakAdmin::OrganizationDomainRepresentation) }
231+
raise ArgumentError.new("All items in domains must be of type OrganizationDomainRepresentation")
232+
end
233+
234+
organization = OrganizationRepresentation.new
235+
organization.name = name
236+
organization.alias = alias_name
237+
organization.enabled = enabled
238+
organization.description = description
239+
organization.redirect_url = redirect_url
240+
organization.domains = domains
241+
organization.attributes = attributes
242+
organization
243+
end
244+
end
245+
end

lib/keycloak-admin/client/realm_client.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ def identity_providers
9595
IdentityProviderClient.new(@configuration, self)
9696
end
9797

98+
def organizations
99+
OrganizationClient.new(@configuration, self)
100+
end
101+
98102
def user(user_id)
99103
UserResource.new(@configuration, self, user_id)
100104
end

lib/keycloak-admin/representation/identity_provider_representation.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class IdentityProviderRepresentation < Representation
1111
:add_read_token_role_on_create,
1212
:authenticate_by_default,
1313
:link_only,
14+
:organization_id,
1415
:first_broker_login_flow_alias,
1516
:config
1617

@@ -30,6 +31,7 @@ def self.from_hash(hash)
3031
hash["addReadTokenRoleOnCreate"],
3132
hash["authenticateByDefault"],
3233
hash["linkOnly"],
34+
hash["organizationId"],
3335
hash["firstBrokerLoginFlowAlias"],
3436
hash["config"]
3537
)
@@ -47,6 +49,7 @@ def initialize(alias_name,
4749
add_read_token_role_on_create,
4850
authenticate_by_default,
4951
link_only,
52+
organization_id,
5053
first_broker_login_flow_alias,
5154
config)
5255
@alias = alias_name
@@ -60,6 +63,7 @@ def initialize(alias_name,
6063
@add_read_token_role_on_create = add_read_token_role_on_create
6164
@authenticate_by_default = authenticate_by_default
6265
@link_only = link_only
66+
@organization_id = organization_id
6367
@first_broker_login_flow_alias = first_broker_login_flow_alias
6468
@config = config || {}
6569
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module KeycloakAdmin
2+
class MemberRepresentation < UserRepresentation
3+
attr_accessor :membership_type
4+
5+
def self.from_hash(hash)
6+
member = super(hash)
7+
member.membership_type = hash["membershipType"]
8+
member
9+
end
10+
end
11+
end

0 commit comments

Comments
 (0)