Skip to content

Commit

Permalink
Implement SharedIDPs API (#251)
Browse files Browse the repository at this point in the history
* 248 Add organizations configuration

* 248 Format

* 248 Fix comments

* #249 Start implementation

* #249 Identify shared idps code changes

* #249 Test update identity provider

* #249 Test remove organization for shared identity provider

* #249 Add identity provider roles restrictions

* #249 Add create admin user global config logic

* #249 Add changes

Remove all identity providers IDP when sharedIdps disabled.
Remove sharedIdps from /linked request body

* #249 Add OrgAddUser Authenticator logic for shared IDPs

* #249 Change logic for  `Add User to Org` to add users based on the email domain
  • Loading branch information
rtufisi authored Jul 9, 2024
1 parent 77e410e commit fdd00a0
Show file tree
Hide file tree
Showing 18 changed files with 1,024 additions and 74 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ The extensions herein are used in the [Phase Two](https://phasetwo.io) cloud off
- [IdP Discovery](#idp-discovery)
- [Import/Export organizations](#importexport-organizations)
- [Active Organization](#active-organization)
- [Global organization settings](#global-organization-settings)
- [Organizations shared IDPs](#organizations-shared-idps)
- [License](#license)

## Overview
Expand Down Expand Up @@ -201,6 +203,20 @@ These are the configuration options for the "Home IdP Discovery" Authenticator.
It is possible to define an active organization and switch it. It's currently based on user's attribute and the active organization id, name, role or attribute can be mapped into tokens with a configurable mapper.
For more information you can refer to [active-organization](./docs/active-organization.md).

### Global organization settings

In the `Organizations` tab it is possible to switch between two master configuration settings: "Create Admin User" and "Shared IDPs"

The `Create Admin User` setting controls the creation of the initial administrator when a new organization is created.
The `Shared IDPs` will give a keycloak admin user the possibility to control the assignment of a Keycloak identity provider in the context of multiple organization. If turned `on`the same IDP can be shared between multiple organizations. If turned `off` a IDP can be assigned to one organization. Switching this setting from `on` to `off` will erase all the IDP settings the current organizations have.
These configs are persisted in the realm config under the flags `_providerConfig.orgs.config.createAdminUser` and `_providerConfig.orgs.config.sharedIdps`

### Organizations shared IDPs

It is possible to share the same IDP between multiple organizations by switching `on` the `Shared IDPs` config.
This offers the possibility to login using the same IDP to different organizations by using the [IdP Discovery](#idp-discovery) method.
For a shared IdP if the `Post login flow` authentication flow is set to `post org broker login` the `Add User to Org` authenticator will add the new member to all organizations which contain the user email domain in their domains configuration list.

## License

We’ve changed the license of our core extensions from the AGPL v3 to the [Elastic License v2](https://github.com/elastic/elasticsearch/blob/main/licenses/ELASTIC-LICENSE-2.0.txt).
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/io/phasetwo/service/Orgs.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public class Orgs {
public static final String KC_ORGS_SKIP_MIGRATION = System.getenv("KC_ORGS_SKIP_MIGRATION");
public static final String ORG_BROWSER_AUTH_FLOW_ALIAS = "Org Browser Flow";
public static final String ORG_DIRECT_GRANT_AUTH_FLOW_ALIAS = "Org Direct Grant Flow";
public static final String ORG_CONFIG_CREATE_ADMIN_USER_KEY =
"_providerConfig.orgs.config.createAdminUser";
public static final String ORG_CONFIG_SHARED_IDPS_KEY = "_providerConfig.orgs.config.sharedIdps";
public static final String ORG_SHARED_IDP_KEY = "home.idp.discovery.shared";
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package io.phasetwo.service.auth;

import static io.phasetwo.service.Orgs.ORG_OWNER_CONFIG_KEY;
import static io.phasetwo.service.Orgs.ORG_SHARED_IDP_KEY;
import static org.keycloak.events.EventType.IDENTITY_PROVIDER_POST_LOGIN;

import com.google.auto.service.AutoService;
import io.phasetwo.service.model.InvitationModel;
import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.model.OrganizationProvider;
import io.phasetwo.service.model.OrganizationRoleModel;
import io.phasetwo.service.util.Domains;
import io.phasetwo.service.util.IdentityProviders;
import java.util.Map;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.authentication.AuthenticationFlowContext;
Expand Down Expand Up @@ -49,17 +52,65 @@ private void addUser(AuthenticationFlowContext context) {
if (!PostOrgAuthFlow.brokeredIdpEnabled(context, brokerContext)) return;

Map<String, String> idpConfig = brokerContext.getIdpConfig().getConfig();
if (idpConfig != null && idpConfig.containsKey(ORG_OWNER_CONFIG_KEY)) {
var idpIsShared = Boolean.parseBoolean(idpConfig.getOrDefault(ORG_SHARED_IDP_KEY, "false"));

if (idpConfig.containsKey(ORG_OWNER_CONFIG_KEY)) {
OrganizationProvider orgs = context.getSession().getProvider(OrganizationProvider.class);
OrganizationModel org =
orgs.getOrganizationById(context.getRealm(), idpConfig.get(ORG_OWNER_CONFIG_KEY));
if (org == null) {
log.infof(
"idpConfig contained %s = %s, but org not found",
ORG_OWNER_CONFIG_KEY, idpConfig.get(ORG_OWNER_CONFIG_KEY));
return;
}
if (!org.hasMembership(context.getUser())) {
var orgIds = IdentityProviders.getAttributeMultivalued(idpConfig, ORG_OWNER_CONFIG_KEY);

orgIds.forEach(
orgId -> {
OrganizationModel org = orgs.getOrganizationById(context.getRealm(), orgId);
if (org == null) {
log.infof(
"idpConfig %s contained %s, but org not found", ORG_OWNER_CONFIG_KEY, orgId);
return;
}

handleOrganizationMembership(context, org, idpIsShared);

if (org.hasMembership(context.getUser())) {
orgs.getUserInvitationsStream(context.getRealm(), context.getUser())
.filter(
invitationModel ->
invitationModel.getOrganization().getId().equals(org.getId()))
.forEach(
invitationModel -> {
addRolesFromInvitation(invitationModel, context.getUser());

invitationModel.getOrganization().revokeInvitation(invitationModel.getId());
context
.getEvent()
.clone()
.event(IDENTITY_PROVIDER_POST_LOGIN)
.detail("org_id", invitationModel.getOrganization().getId())
.detail("invitation_id", invitationModel.getId())
.user(context.getUser())
.error("User invitation revoked.");
});
}
});
} else {
log.infof("No organization owns IdP %s", brokerContext.getIdpConfig().getAlias());
}
}

private static void handleOrganizationMembership(
AuthenticationFlowContext context, OrganizationModel org, boolean idpIsShared) {
if (!org.hasMembership(context.getUser()) && !idpIsShared) {
log.infof(
"granting membership to %s for user %s", org.getName(), context.getUser().getUsername());
org.grantMembership(context.getUser());
context
.getEvent()
.user(context.getUser())
.detail("joined_organization", org.getId())
.success();
}

if (!org.hasMembership(context.getUser()) && idpIsShared) {
var userDomain = Domains.extract(context.getUser().getEmail());
if (userDomain.isPresent() && Domains.supportsDomain(org.getDomains(), userDomain.get())) {
log.infof(
"granting membership to %s for user %s",
org.getName(), context.getUser().getUsername());
Expand All @@ -69,26 +120,7 @@ private void addUser(AuthenticationFlowContext context) {
.user(context.getUser())
.detail("joined_organization", org.getId())
.success();
orgs.getUserInvitationsStream(context.getRealm(), context.getUser())
.filter(
invitationModel -> invitationModel.getOrganization().getId().equals(org.getId()))
.forEach(
invitationModel -> {
addRolesFromInvitation(invitationModel, context.getUser());

invitationModel.getOrganization().revokeInvitation(invitationModel.getId());
context
.getEvent()
.clone()
.event(IDENTITY_PROVIDER_POST_LOGIN)
.detail("org_id", invitationModel.getOrganization().getId())
.detail("invitation_id", invitationModel.getId())
.user(context.getUser())
.error("User invitation revoked.");
});
}
} else {
log.infof("No organization owns IdP %s", brokerContext.getIdpConfig().getAlias());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.model.OrganizationRoleModel;
import io.phasetwo.service.resource.OrganizationResourceProviderFactory;
import io.phasetwo.service.util.IdentityProviders;
import org.keycloak.models.IdentityProviderModel;

public final class KeycloakOrgsExportConverter {
Expand Down Expand Up @@ -104,7 +105,8 @@ private static OrganizationAttributes convertOrganizationModelToOrganizationAttr
}

private static boolean idpInOrg(IdentityProviderModel provider, String orgId) {
return (provider.getConfig().containsKey(ORG_OWNER_CONFIG_KEY)
&& orgId.equals(provider.getConfig().get(ORG_OWNER_CONFIG_KEY)));
var orgs =
IdentityProviders.getAttributeMultivalued(provider.getConfig(), ORG_OWNER_CONFIG_KEY);
return orgs.contains(orgId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
import io.phasetwo.service.model.InvitationModel;
import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.resource.OrganizationAdminAuth;
import io.phasetwo.service.util.IdentityProviders;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
Expand Down Expand Up @@ -111,7 +113,8 @@ public static void createOrganizationIdp(
if (Objects.nonNull(idpLink)) {
IdentityProviderModel idp = realm.getIdentityProviderByAlias(idpLink);
if (Objects.nonNull(idp)) {
idp.getConfig().put(ORG_OWNER_CONFIG_KEY, org.getId());
IdentityProviders.setAttributeMultivalued(
idp.getConfig(), ORG_OWNER_CONFIG_KEY, Set.of(org.getId()));
realm.updateIdentityProvider(idp);
} else {
if (skipMissingIdp) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import io.phasetwo.service.model.jpa.entity.OrganizationMemberEntity;
import io.phasetwo.service.model.jpa.entity.OrganizationRoleEntity;
import io.phasetwo.service.model.jpa.entity.UserOrganizationRoleMappingEntity;
import io.phasetwo.service.util.IdentityProviders;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import java.util.List;
Expand Down Expand Up @@ -320,12 +321,13 @@ public OrganizationRoleModel addRole(String name) {
public Stream<IdentityProviderModel> getIdentityProvidersStream() {
return getRealm()
.getIdentityProvidersStream()
// Todo: do we need to apply here a role filter? I believe not since its part of the
// HomeIdpDiscoverer
.filter(
i -> {
Map<String, String> config = i.getConfig();
return config != null
&& config.containsKey(ORG_OWNER_CONFIG_KEY)
&& getId().equals(config.get(ORG_OWNER_CONFIG_KEY));
var orgs = IdentityProviders.getAttributeMultivalued(config, ORG_OWNER_CONFIG_KEY);
return orgs.contains(getId());
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.phasetwo.service.representation;

import com.fasterxml.jackson.annotation.JsonProperty;

public class OrganizationsConfig {
@JsonProperty("createAdminUserEnabled")
private boolean createAdminUser = true;

@JsonProperty("sharedIdpsEnabled")
private boolean sharedIdps = false;

public boolean isCreateAdminUser() {
return createAdminUser;
}

public void setCreateAdminUser(boolean createAdminUser) {
this.createAdminUser = createAdminUser;
}

public boolean isSharedIdps() {
return sharedIdps;
}

public void setSharedIdps(boolean sharedIdps) {
this.sharedIdps = sharedIdps;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package io.phasetwo.service.resource;

import static io.phasetwo.service.Orgs.*;
import static io.phasetwo.service.resource.Converters.*;
import static io.phasetwo.service.resource.OrganizationResourceType.*;

import io.phasetwo.service.model.OrganizationModel;
import jakarta.validation.constraints.*;
import io.phasetwo.service.util.IdentityProviders;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
Expand Down Expand Up @@ -60,8 +58,8 @@ public Response unlinkIdp() {
if (idp == null) {
throw new NotFoundException(String.format("No IdP found with alias %s", alias));
}
IdentityProviders.removeOrganization(organization.getId(), idp);

idp.getConfig().remove(ORG_OWNER_CONFIG_KEY);
realm.updateIdentityProvider(idp);
return Response.noContent().build();
}
Expand All @@ -70,9 +68,17 @@ public Response unlinkIdp() {
@Consumes(MediaType.APPLICATION_JSON)
public Response update(IdentityProviderRepresentation providerRep) {
requireManage();
IdentityProviderModel idp = realm.getIdentityProviderByAlias(alias);
if (idp == null) {
throw new NotFoundException(String.format("No IdP found with alias %s", alias));
}
var orgs = IdentityProviders.getAttributeMultivalued(idp.getConfig(), ORG_OWNER_CONFIG_KEY);

// don't allow override of ownership and other conf vars
providerRep.getConfig().put("hideOnLoginPage", "true");
providerRep.getConfig().put(ORG_OWNER_CONFIG_KEY, organization.getId());
IdentityProviders.setAttributeMultivalued(providerRep.getConfig(), ORG_OWNER_CONFIG_KEY, orgs);
providerRep.getConfig().put(ORG_SHARED_IDP_KEY, idp.getConfig().get(ORG_SHARED_IDP_KEY));

// force alias
providerRep.setAlias(alias);

Expand Down
Loading

0 comments on commit fdd00a0

Please sign in to comment.