Skip to content

Commit 104c954

Browse files
authored
Add partial organization support (#8)
* Add organizations support without tests * Add tests to organization and fixes * Make organizations tests execution conditional * Use a custom realm for testing organizations * Make organization support for the test realms conditional on keycloak version * Add documentation, missing enabled field in organization and remove some code duplication --------- Co-authored-by: lucdew <[email protected]>
1 parent 2a124f7 commit 104c954

File tree

8 files changed

+813
-3
lines changed

8 files changed

+813
-3
lines changed

docs/resources/organization.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
page_title: "keycloak_organization Resource"
3+
---
4+
5+
# keycloak_organization Resource
6+
7+
Allows for creating and managing Organizations within Keycloak.
8+
9+
It only configures the Organization own data but not the association to the identity providers or organization members.
10+
11+
## Example Usage
12+
13+
```hcl
14+
resource "keycloak_realm" "realm" {
15+
realm = "my-realm"
16+
enabled = true
17+
}
18+
19+
resource "keycloak_organization" "engineering" {
20+
realm_id = keycloak_realm.example.id
21+
name = "engineering"
22+
alias = "engineering"
23+
description = "Organization for the engineering department"
24+
25+
domain {
26+
name = "engineering.example.com"
27+
verified = true
28+
}
29+
30+
domain {
31+
name = "engineering-lab.example.com"
32+
}
33+
34+
attributes = {
35+
department = "technical"
36+
location = "headquarter"
37+
}
38+
}
39+
40+
41+
42+
```
43+
44+
## Argument Reference
45+
46+
- `realm_id` - (Required) The realm this organization exists in.
47+
- `name` - (Required) The name of the organization
48+
- `alias` - (Optional) The alias of the organization. It cannot be updated and must not have spaces. If not set it is computed.
49+
- `enabled` - (Optional) When `false`, members will not be able to access this organization. Defaults to `true`.
50+
- `description` - (Optional) the organization description.
51+
- `redirect_url` - (Optional) the organization redirect url.
52+
- `attributes` - (Optional) A map representing attributes for the organization. In order to add multivalued attributes, use `##` to separate the values. Max length for each value is 255 chars
53+
54+
### Domains
55+
56+
Associated domains can be configured by using 1 or more `domain` block, which supports the following arguments:
57+
58+
- `name` - (Required) The domain name. Must be unique.
59+
- `verified` - (Optional) When `true`, indicates that the domain has been verified. Defaults to `false`.
60+
61+
## Import
62+
63+
Organizations can be imported using the format `{{realm_id}}/{{organization_id}}`, where `organization` is the unique ID that Keycloak assigns to the organization upon creation. This value can be found in the URI when editing this organization in the GUI, and is typically a GUID.
64+
65+
Example:
66+
67+
```bash
68+
$ terraform import keycloak_organization.engineering my-realm/934a4a4e-28bd-4703-a0fa-332df153aabd
69+
```

keycloak/identity_provider.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ type IdentityProviderConfig struct {
5252
AuthnContextComparisonType string `json:"authnContextComparisonType,omitempty"`
5353
AuthnContextDeclRefs types.KeycloakSliceQuoted `json:"authnContextDeclRefs,omitempty"`
5454
Issuer string `json:"issuer,omitempty"`
55+
OrgDomain string `json:"kc.org.domain,omitempty"`
56+
OrgRedirectEmailMatches types.KeycloakBoolQuoted `json:"kc.org.broker.redirect.mode.email-matches,omitempty"`
5557
}
5658

5759
type IdentityProvider struct {
@@ -65,11 +67,12 @@ type IdentityProvider struct {
6567
AddReadTokenRoleOnCreate bool `json:"addReadTokenRoleOnCreate"`
6668
AuthenticateByDefault bool `json:"authenticateByDefault"`
6769
LinkOnly bool `json:"linkOnly"`
68-
HideOnLogin bool `json:"hideOnLogin,omitempty"` //since keycloak v26
70+
HideOnLogin bool `json:"hideOnLogin,omitempty"` // since keycloak v26
6971
TrustEmail bool `json:"trustEmail"`
7072
FirstBrokerLoginFlowAlias string `json:"firstBrokerLoginFlowAlias"`
7173
PostBrokerLoginFlowAlias string `json:"postBrokerLoginFlowAlias"`
7274
Config *IdentityProviderConfig `json:"config"`
75+
OrganizationId string `json:"organizationId,omitempty"` // since keycloak v26
7376
}
7477

7578
func (keycloakClient *KeycloakClient) NewIdentityProvider(ctx context.Context, identityProvider *IdentityProvider) error {

keycloak/organization.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package keycloak
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
type OrganizationDomain struct {
10+
Name string `json:"name,omitempty"`
11+
Verified bool `json:"verified,omitempty"`
12+
}
13+
14+
type Organization struct {
15+
Id string `json:"id,omitempty"`
16+
RealmId string `json:"-"`
17+
Name string `json:"name"`
18+
Alias string `json:"alias,omitempty"`
19+
Enabled bool `json:"enabled"`
20+
RedirectUrl string `json:"redirectUrl,omitempty"`
21+
Description string `json:"description,omitempty"`
22+
Domains []OrganizationDomain `json:"domains,omitempty"`
23+
Attributes map[string][]string `json:"attributes,omitempty"`
24+
}
25+
26+
func (keycloakClient *KeycloakClient) CreateOrganization(ctx context.Context, organization *Organization) error {
27+
path := fmt.Sprintf("/realms/%s/organizations", organization.RealmId)
28+
29+
_, location, err := keycloakClient.post(ctx, path, organization)
30+
if err != nil {
31+
return err
32+
}
33+
34+
// Extract ID from location URL
35+
parts := strings.Split(location, "/")
36+
organization.Id = parts[len(parts)-1]
37+
38+
return nil
39+
}
40+
41+
// GetOrganizationsPath returns the URL for the organization API endpoint
42+
func (keycloakClient *KeycloakClient) GetOrganizationsPath(realmId string) string {
43+
return fmt.Sprintf("/realms/%s/organizations", realmId)
44+
}
45+
46+
// GetOrganizations gets all organizations in a realm
47+
// This function can be used to list all organizations or filter by search criteria
48+
func (keycloakClient *KeycloakClient) GetOrganizations(ctx context.Context, realmId string, params ...map[string]string) ([]*Organization, error) {
49+
var organizations []*Organization
50+
queryParams := make(map[string]string)
51+
52+
// Apply optional filter parameters
53+
if len(params) > 0 && params[0] != nil {
54+
for k, v := range params[0] {
55+
queryParams[k] = v
56+
}
57+
}
58+
59+
err := keycloakClient.get(ctx, keycloakClient.GetOrganizationsPath(realmId), &organizations, queryParams)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to fetch organizations: %w", err)
62+
}
63+
64+
// Set RealmId for each organization
65+
for _, organization := range organizations {
66+
organization.RealmId = realmId
67+
}
68+
69+
return organizations, nil
70+
}
71+
72+
// GetOrganizationsPaginated gets organizations with pagination support
73+
func (keycloakClient *KeycloakClient) GetOrganizationsPaginated(ctx context.Context, realmId string, first, max int, search string) ([]*Organization, error) {
74+
queryParams := map[string]string{
75+
"first": fmt.Sprintf("%d", first),
76+
"max": fmt.Sprintf("%d", max),
77+
}
78+
79+
if search != "" {
80+
queryParams["search"] = search
81+
}
82+
83+
return keycloakClient.GetOrganizations(ctx, realmId, queryParams)
84+
}
85+
86+
// GetOrganizationByName gets an organization by name
87+
func (keycloakClient *KeycloakClient) GetOrganizationByName(ctx context.Context, realmId, name string) (*Organization, error) {
88+
var organizations []*Organization
89+
90+
params := map[string]string{
91+
"search": name,
92+
"exact": "true",
93+
}
94+
95+
err := keycloakClient.get(ctx, keycloakClient.GetOrganizationsPath(realmId), &organizations, params)
96+
if err != nil {
97+
return nil, err
98+
}
99+
100+
// Find the exact match by name
101+
for _, organization := range organizations {
102+
if organization.Name == name {
103+
organization.RealmId = realmId
104+
return organization, nil
105+
}
106+
}
107+
108+
return nil, nil
109+
}
110+
111+
func (keycloakClient *KeycloakClient) GetOrganization(ctx context.Context, realmId, id string) (*Organization, error) {
112+
organization := Organization{}
113+
organization.RealmId = realmId
114+
115+
path := fmt.Sprintf("/realms/%s/organizations/%s", realmId, id)
116+
err := keycloakClient.get(ctx, path, &organization, nil)
117+
if err != nil {
118+
return nil, err
119+
}
120+
121+
return &organization, nil
122+
}
123+
124+
func (keycloakClient *KeycloakClient) UpdateOrganization(ctx context.Context, organization *Organization) error {
125+
path := fmt.Sprintf("/realms/%s/organizations/%s", organization.RealmId, organization.Id)
126+
return keycloakClient.put(ctx, path, organization)
127+
}
128+
129+
func (keycloakClient *KeycloakClient) DeleteOrganization(ctx context.Context, realmId, id string) error {
130+
path := fmt.Sprintf("/realms/%s/organizations/%s", realmId, id)
131+
return keycloakClient.delete(ctx, path, nil)
132+
}

provider/generic_keycloak_identity_provider.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
99
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1010
"github.com/keycloak/terraform-provider-keycloak/keycloak"
11+
"github.com/keycloak/terraform-provider-keycloak/keycloak/types"
1112
"reflect"
1213
"strings"
1314
)
@@ -18,8 +19,10 @@ var syncModes = []string{
1819
"LEGACY",
1920
}
2021

21-
type identityProviderDataGetterFunc func(data *schema.ResourceData, keycloakVersion *version.Version) (*keycloak.IdentityProvider, error)
22-
type identityProviderDataSetterFunc func(data *schema.ResourceData, identityProvider *keycloak.IdentityProvider, keycloakVersion *version.Version) error
22+
type (
23+
identityProviderDataGetterFunc func(data *schema.ResourceData, keycloakVersion *version.Version) (*keycloak.IdentityProvider, error)
24+
identityProviderDataSetterFunc func(data *schema.ResourceData, identityProvider *keycloak.IdentityProvider, keycloakVersion *version.Version) error
25+
)
2326

2427
func resourceKeycloakIdentityProvider() *schema.Resource {
2528
return &schema.Resource{
@@ -34,6 +37,7 @@ func resourceKeycloakIdentityProvider() *schema.Resource {
3437
ForceNew: true,
3538
Description: "The alias uniquely identifies an identity provider and it is also used to build the redirect uri.",
3639
},
40+
3741
"realm": {
3842
Type: schema.TypeString,
3943
Required: true,
@@ -119,6 +123,28 @@ func resourceKeycloakIdentityProvider() *schema.Resource {
119123
ValidateFunc: validation.StringInSlice(syncModes, false),
120124
Description: "Sync Mode",
121125
},
126+
"organization": {
127+
Type: schema.TypeList,
128+
Optional: true,
129+
MaxItems: 1,
130+
Elem: &schema.Resource{
131+
Schema: map[string]*schema.Schema{
132+
"organization_id": {
133+
Type: schema.TypeString,
134+
Required: true,
135+
},
136+
"domain": {
137+
Type: schema.TypeString,
138+
Optional: true,
139+
Default: "",
140+
},
141+
"redirect_email_domain_matches": {
142+
Type: schema.TypeBool,
143+
Optional: true,
144+
},
145+
},
146+
},
147+
},
122148
},
123149
}
124150
}
@@ -145,6 +171,19 @@ func getIdentityProviderFromData(data *schema.ResourceData, keycloakVersion *ver
145171
PostBrokerLoginFlowAlias: data.Get("post_broker_login_flow_alias").(string),
146172
InternalId: data.Get("internal_id").(string),
147173
}
174+
175+
if v, ok := data.GetOk("organization"); ok {
176+
organizationSettings := v.([]interface{})[0].(map[string]interface{})
177+
identityProvider.OrganizationId = organizationSettings["organization_id"].(string)
178+
179+
if v, ok := organizationSettings["domain"]; ok {
180+
defaultIdentityProviderConfig.OrgDomain = v.(string)
181+
}
182+
if v, ok = organizationSettings["redirect_email_domain_matches"]; ok {
183+
defaultIdentityProviderConfig.OrgRedirectEmailMatches = types.KeycloakBoolQuoted(reflect.ValueOf(v).Bool())
184+
}
185+
}
186+
148187
if keycloakVersion.GreaterThanOrEqual(keycloak.Version_26.AsVersion()) {
149188
// Since keycloak v26 the attribute is moved from Config to Provider.
150189
identityProvider.HideOnLogin = data.Get("hide_on_login_page").(bool)
@@ -173,6 +212,17 @@ func setIdentityProviderData(data *schema.ResourceData, identityProvider *keyclo
173212
data.Set("hide_on_login_page", identityProvider.HideOnLogin)
174213
}
175214

215+
if identityProvider.OrganizationId != "" {
216+
organizationSettings := make(map[string]interface{})
217+
organizationSettings["organization_id"] = identityProvider.OrganizationId
218+
organizationSettings["redirect_email_domain_matches"] = identityProvider.Config.OrgRedirectEmailMatches
219+
organizationSettings["domain"] = identityProvider.Config.OrgDomain
220+
221+
data.Set("organization", []interface{}{organizationSettings})
222+
} else {
223+
data.Set("organization", nil)
224+
}
225+
176226
// identity provider config
177227
data.Set("gui_order", identityProvider.Config.GuiOrder)
178228
data.Set("sync_mode", identityProvider.Config.SyncMode)

provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ func KeycloakProvider(client *keycloak.KeycloakClient) *schema.Provider {
121121
"keycloak_user_groups": resourceKeycloakUserGroups(),
122122
"keycloak_group_permissions": resourceKeycloakGroupPermissions(),
123123
"keycloak_authentication_bindings": resourceKeycloakAuthenticationBindings(),
124+
"keycloak_organization": resourceKeycloakOrganization(),
124125
},
125126
Schema: map[string]*schema.Schema{
126127
"client_id": {

provider/provider_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ func createTestRealm(testCtx context.Context) *keycloak.Realm {
113113
Realm: name,
114114
Enabled: true,
115115
}
116+
if ok, _ := keycloakClient.VersionIsGreaterThanOrEqualTo(testCtx, keycloak.Version_26); ok {
117+
r.OrganizationsEnabled = true
118+
}
116119

117120
var err error
118121
for i := 0; i < 3; i++ { // on CI this sometimes fails and keycloak can't be reached

0 commit comments

Comments
 (0)