From f03f61d4d3edf4273c23033877a2d0735a95f572 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Thu, 13 Feb 2020 22:04:05 +1100 Subject: [PATCH 1/3] Write SAML SP data to a dedicated index This adds low level classes for reading & writing documents that model a SAML Service Provider in the IdP. (These classes are not currently used) --- .../xpack/core/ClientHelper.java | 1 + .../saml/sp/SamlServiceProviderDocument.java | 404 ++++++++++++++++++ .../idp/saml/sp/SamlServiceProviderIndex.java | 189 ++++++++ .../index/saml-service-provider-template.json | 91 ++++ .../sp/SamlServiceProviderDocumentTests.java | 132 ++++++ .../sp/SamlServiceProviderIndexTests.java | 226 ++++++++++ .../security/authz/AuthorizationUtils.java | 2 + 7 files changed, 1045 insertions(+) create mode 100644 x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java create mode 100644 x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java create mode 100644 x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json create mode 100644 x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java create mode 100644 x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ClientHelper.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ClientHelper.java index ed03ab9ffc058..49a457f0a7f55 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ClientHelper.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ClientHelper.java @@ -53,6 +53,7 @@ public final class ClientHelper { public static final String ENRICH_ORIGIN = "enrich"; public static final String TRANSFORM_ORIGIN = "transform"; public static final String ASYNC_SEARCH_ORIGIN = "async_search"; + public static final String IDP_ORIGIN = "idp"; private ClientHelper() {} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java new file mode 100644 index 0000000000000..5e0a47ba56daf --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java @@ -0,0 +1,404 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.idp.saml.sp; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.xpack.core.ssl.CertParsingUtils; +import org.joda.time.Duration; +import org.joda.time.ReadableDuration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +/** + * This class models the storage of a {@link SamlServiceProvider} as an Elasticsearch document. + */ +public class SamlServiceProviderDocument implements ToXContentObject { + + public static class Privileges { + @Nullable + public String application; + public String resource; + @Nullable + public String loginAction; + public Map groupActions; + + public void setApplication(String application) { + this.application = application; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public void setLoginAction(String loginAction) { + this.loginAction = loginAction; + } + + public void setGroupActions(Map groupActions) { + this.groupActions = groupActions; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Privileges that = (Privileges) o; + return Objects.equals(application, that.application) && + Objects.equals(resource, that.resource) && + Objects.equals(loginAction, that.loginAction) && + Objects.equals(groupActions, that.groupActions); + } + + @Override + public int hashCode() { + return Objects.hash(application, resource, loginAction, groupActions); + } + } + + public static class AttributeNames { + public String principal; + @Nullable + public String email; + @Nullable + public String name; + @Nullable + public String groups; + + public void setPrincipal(String principal) { + this.principal = principal; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setName(String name) { + this.name = name; + } + + public void setGroups(String groups) { + this.groups = groups; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final AttributeNames that = (AttributeNames) o; + return Objects.equals(principal, that.principal) && + Objects.equals(email, that.email) && + Objects.equals(name, that.name) && + Objects.equals(groups, that.groups); + } + + @Override + public int hashCode() { + return Objects.hash(principal, email, name, groups); + } + } + + public String docId; + + public String name; + + public String entityId; + + public String acs; + + public boolean enabled = true; + public Instant created; + public Instant lastModified; + + @Nullable + public String nameIdFormat; + + @Nullable + public Long authenticationExpiryMillis; + + public List signingCertificates = List.of(); + + public final Privileges privileges = new Privileges(); + public final AttributeNames attributeNames = new AttributeNames(); + + public String getDocId() { + return docId; + } + + public void setDocId(String docId) { + this.docId = docId; + } + + public void setName(String name) { + this.name = name; + } + + public void setEntityId(String entityId) { + this.entityId = entityId; + } + + public void setAcs(String acs) { + this.acs = acs; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setCreatedMillis(Long millis) { + this.created = Instant.ofEpochMilli(millis); + } + + public void setLastModifiedMillis(Long millis) { + this.lastModified = Instant.ofEpochMilli(millis); + } + + public void setNameIdFormat(String nameIdFormat) { + this.nameIdFormat = nameIdFormat; + } + + public void setAuthenticationExpiryMillis(Long authenticationExpiryMillis) { + this.authenticationExpiryMillis = authenticationExpiryMillis; + } + + public void setAuthenticationExpiry(ReadableDuration authnExpiry) { + this.authenticationExpiryMillis = authnExpiry.getMillis(); + } + + public ReadableDuration getAuthenticationExpiryMillis() { + return Duration.millis(this.authenticationExpiryMillis); + } + + public void setSigningCertificates(Collection signingCertificates) { + this.signingCertificates = signingCertificates == null ? List.of() : List.copyOf(signingCertificates); + } + + public void setX509SigningCertificates(Collection certificates) throws CertificateEncodingException { + this.signingCertificates = certificates == null ? List.of() : certificates.stream() + .map(cert -> { + try { + return cert.getEncoded(); + } catch (CertificateEncodingException e) { + throw new ElasticsearchException("Cannot read certificate", e); + } + }) + .map(Base64.getEncoder()::encodeToString) + .collect(Collectors.toUnmodifiableList()); + } + + public List getX509SigningCertificates() { + if (this.signingCertificates == null || this.signingCertificates.isEmpty()) { + return List.of(); + } + return this.signingCertificates.stream().map(this::toX509Certificate).collect(Collectors.toUnmodifiableList()); + } + + private X509Certificate toX509Certificate(String base64Cert) { + final byte[] bytes = base64Cert.getBytes(StandardCharsets.UTF_8); + try (InputStream stream = new ByteArrayInputStream(bytes)) { + final List certificates = CertParsingUtils.readCertificates(Base64.getDecoder().wrap(stream)); + if (certificates.size() == 1) { + final Certificate certificate = certificates.get(0); + if (certificate instanceof X509Certificate) { + return (X509Certificate) certificate; + } else { + throw new ElasticsearchException("Certificate ({}) is not a X.509 certificate", certificate.getClass()); + } + } else { + throw new ElasticsearchException("Expected a single certificate, but found {}", certificates.size()); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (CertificateException e) { + throw new ElasticsearchException("Cannot parse certificate(s)", e); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SamlServiceProviderDocument that = (SamlServiceProviderDocument) o; + return Objects.equals(docId, that.docId) && + Objects.equals(name, that.name) && + Objects.equals(entityId, that.entityId) && + Objects.equals(acs, that.acs) && + Objects.equals(enabled, that.enabled) && + Objects.equals(created, that.created) && + Objects.equals(lastModified, that.lastModified) && + Objects.equals(nameIdFormat, that.nameIdFormat) && + Objects.equals(authenticationExpiryMillis, that.authenticationExpiryMillis) && + Objects.equals(signingCertificates, that.signingCertificates) && + Objects.equals(privileges, that.privileges) && + Objects.equals(attributeNames, that.attributeNames); + } + + @Override + public int hashCode() { + return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormat, authenticationExpiryMillis, + signingCertificates, privileges, attributeNames); + } + + private static final ObjectParser DOC_PARSER + = new ObjectParser<>("service_provider_doc", true, SamlServiceProviderDocument::new); + private static final ObjectParser PRIVILEGES_PARSER = new ObjectParser<>("service_provider_priv", true, null); + private static final ObjectParser ATTRIBUTES_PARSER = new ObjectParser<>("service_provider_attr", true, null); + + private static final BiConsumer NULL_CONSUMER = (doc, obj) -> { + }; + + static { + DOC_PARSER.declareString(SamlServiceProviderDocument::setName, Fields.NAME); + DOC_PARSER.declareString(SamlServiceProviderDocument::setEntityId, Fields.ENTITY_ID); + DOC_PARSER.declareString(SamlServiceProviderDocument::setAcs, Fields.ACS); + DOC_PARSER.declareBoolean(SamlServiceProviderDocument::setEnabled, Fields.ENABLED); + DOC_PARSER.declareLong(SamlServiceProviderDocument::setCreatedMillis, Fields.CREATED_DATE); + DOC_PARSER.declareLong(SamlServiceProviderDocument::setLastModifiedMillis, Fields.LAST_MODIFIED); + DOC_PARSER.declareStringOrNull(SamlServiceProviderDocument::setNameIdFormat, Fields.NAME_ID); + // Using a method reference here angers some compilers + DOC_PARSER.declareField(SamlServiceProviderDocument::setAuthenticationExpiryMillis, + parser -> parser.currentToken() == XContentParser.Token.VALUE_NULL ? null : parser.longValue(), + Fields.AUTHN_EXPIRY, ObjectParser.ValueType.LONG_OR_NULL); + DOC_PARSER.declareStringArray(SamlServiceProviderDocument::setSigningCertificates, Fields.SIGNING_CERT); + + DOC_PARSER.declareObject(NULL_CONSUMER, (parser, doc) -> PRIVILEGES_PARSER.parse(parser, doc.privileges, null), Fields.PRIVILEGES); + PRIVILEGES_PARSER.declareStringOrNull(Privileges::setApplication, Fields.Privileges.APPLICATION); + PRIVILEGES_PARSER.declareString(Privileges::setResource, Fields.Privileges.RESOURCE); + PRIVILEGES_PARSER.declareStringOrNull(Privileges::setLoginAction, Fields.Privileges.LOGIN_ACTION); + PRIVILEGES_PARSER.declareField(Privileges::setGroupActions, + (parser, ignore) -> parser.currentToken() == XContentParser.Token.VALUE_NULL ? null : parser.mapStrings(), + Fields.Privileges.GROUPS, ObjectParser.ValueType.OBJECT_OR_NULL); + + DOC_PARSER.declareObject(NULL_CONSUMER, (p, doc) -> ATTRIBUTES_PARSER.parse(p, doc.attributeNames, null), Fields.ATTRIBUTES); + ATTRIBUTES_PARSER.declareString(AttributeNames::setPrincipal, Fields.Attributes.PRINCIPAL); + ATTRIBUTES_PARSER.declareStringOrNull(AttributeNames::setEmail, Fields.Attributes.EMAIL); + ATTRIBUTES_PARSER.declareStringOrNull(AttributeNames::setName, Fields.Attributes.NAME); + ATTRIBUTES_PARSER.declareStringOrNull(AttributeNames::setGroups, Fields.Attributes.GROUPS); + } + + public static SamlServiceProviderDocument fromXContent(String docId, XContentParser parser) throws IOException { + SamlServiceProviderDocument doc = new SamlServiceProviderDocument(); + doc.setDocId(docId); + return DOC_PARSER.parse(parser, doc, doc); + } + + public ValidationException validate() { + final ValidationException validation = new ValidationException(); + if (Strings.isNullOrEmpty(name)) { + validation.addValidationError("field [" + Fields.NAME.getPreferredName() + "] is required, but was [" + name + "]"); + } + if (Strings.isNullOrEmpty(entityId)) { + validation.addValidationError("field [" + Fields.ENTITY_ID.getPreferredName() + "] is required, but was [" + entityId + "]"); + } + if (Strings.isNullOrEmpty(acs)) { + validation.addValidationError("field [" + Fields.ACS.getPreferredName() + "] is required, but was [" + acs + "]"); + } + if (created == null) { + validation.addValidationError("field [" + Fields.CREATED_DATE.getPreferredName() + "] is required, but was [" + created + "]"); + } + if (lastModified == null) { + validation.addValidationError( + "field [" + Fields.LAST_MODIFIED.getPreferredName() + "] is required, but was [" + lastModified + "]"); + } + if (Strings.isNullOrEmpty(privileges.resource)) { + validation.addValidationError("field [" + Fields.PRIVILEGES.getPreferredName() + "." + + Fields.Privileges.RESOURCE.getPreferredName() + "] is required, but was [" + privileges.resource + "]"); + } + if (Strings.isNullOrEmpty(attributeNames.principal)) { + validation.addValidationError("field [" + Fields.ATTRIBUTES.getPreferredName() + "." + + Fields.Attributes.PRINCIPAL.getPreferredName() + "] is required, but was [" + attributeNames.principal + "]"); + } + if (validation.validationErrors().isEmpty()) { + return null; + } else { + return validation; + } + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(Fields.NAME.getPreferredName(), name); + builder.field(Fields.ENTITY_ID.getPreferredName(), entityId); + builder.field(Fields.ACS.getPreferredName(), acs); + builder.field(Fields.ENABLED.getPreferredName(), enabled); + builder.field(Fields.CREATED_DATE.getPreferredName(), created == null ? null : created.toEpochMilli()); + builder.field(Fields.LAST_MODIFIED.getPreferredName(), lastModified == null ? null : lastModified.toEpochMilli()); + builder.field(Fields.NAME_ID.getPreferredName(), nameIdFormat); + builder.field(Fields.AUTHN_EXPIRY.getPreferredName(), authenticationExpiryMillis); + builder.field(Fields.SIGNING_CERT.getPreferredName(), signingCertificates == null ? List.of() : signingCertificates); + + builder.startObject(Fields.PRIVILEGES.getPreferredName()); + builder.field(Fields.Privileges.APPLICATION.getPreferredName(), privileges.application); + builder.field(Fields.Privileges.RESOURCE.getPreferredName(), privileges.resource); + builder.field(Fields.Privileges.LOGIN_ACTION.getPreferredName(), privileges.loginAction); + builder.field(Fields.Privileges.GROUPS.getPreferredName(), privileges.groupActions); + builder.endObject(); + + builder.startObject(Fields.ATTRIBUTES.getPreferredName()); + builder.field(Fields.Attributes.PRINCIPAL.getPreferredName(), attributeNames.principal); + builder.field(Fields.Attributes.EMAIL.getPreferredName(), attributeNames.email); + builder.field(Fields.Attributes.NAME.getPreferredName(), attributeNames.name); + builder.field(Fields.Attributes.GROUPS.getPreferredName(), attributeNames.groups); + builder.endObject(); + + return builder.endObject(); + } + + interface Fields { + ParseField NAME = new ParseField("name"); + ParseField ENTITY_ID = new ParseField("entity_id"); + ParseField ACS = new ParseField("acs"); + ParseField ENABLED = new ParseField("enabled"); + ParseField NAME_ID = new ParseField("name_id_format"); + ParseField AUTHN_EXPIRY = new ParseField("authn_expiry_ms"); + ParseField SIGNING_CERT = new ParseField("signing_cert"); + + ParseField CREATED_DATE = new ParseField("created"); + ParseField LAST_MODIFIED = new ParseField("last_modified"); + + ParseField PRIVILEGES = new ParseField("privileges"); + ParseField ATTRIBUTES = new ParseField("attributes"); + + interface Privileges { + ParseField APPLICATION = new ParseField("application"); + ParseField RESOURCE = new ParseField("resource"); + ParseField LOGIN_ACTION = new ParseField("login"); + ParseField GROUPS = new ParseField("groups"); + } + + interface Attributes { + ParseField PRINCIPAL = new ParseField("principal"); + ParseField EMAIL = new ParseField("email"); + ParseField NAME = new ParseField("name"); + ParseField GROUPS = new ParseField("groups"); + } + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java new file mode 100644 index 0000000000000..13d23623a32e8 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.idp.saml.sp; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.admin.indices.refresh.RefreshRequest; +import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest; +import org.elasticsearch.action.get.GetRequest; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.client.Client; +import org.elasticsearch.client.OriginSettingClient; +import org.elasticsearch.cluster.ClusterChangedEvent; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.ClusterStateListener; +import org.elasticsearch.cluster.metadata.AliasOrIndex; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.xpack.core.ClientHelper; +import org.elasticsearch.xpack.core.template.TemplateUtils; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * This class provides utility methods to read/write {@link SamlServiceProviderDocument} to an Elasticsearch index. + */ +public class SamlServiceProviderIndex implements Closeable { + + private final Logger logger = LogManager.getLogger(); + + private final Client client; + private final ClusterService clusterService; + private volatile boolean aliasExists; + + static final String ALIAS_NAME = "saml-service-provider"; + static final String INDEX_NAME = "saml-service-provider-v1"; + static final String TEMPLATE_NAME = ALIAS_NAME; + + private static final String TEMPLATE_RESOURCE = "/index/saml-service-provider-template.json"; + private static final String TEMPLATE_META_VERSION_KEY = "idp-version"; + private static final String TEMPLATE_VERSION_SUBSTITUTE = Pattern.quote("${idp.template.version}"); + private final ClusterStateListener clusterStateListener; + + public SamlServiceProviderIndex(Client client, ClusterService clusterService) { + this.client = new OriginSettingClient(client, ClientHelper.IDP_ORIGIN); + this.clusterService = clusterService; + clusterStateListener = this::clusterChanged; + clusterService.addListener(clusterStateListener); + } + + private void clusterChanged(ClusterChangedEvent clusterChangedEvent) { + final ClusterState state = clusterChangedEvent.state(); + final AliasOrIndex aliasInfo = state.getMetaData().getAliasAndIndexLookup().get(ALIAS_NAME); + final boolean previousState = aliasExists; + this.aliasExists = aliasInfo != null; + if (aliasExists != previousState) { + logChangedAliasState(aliasInfo); + } + } + + @Override + public void close() { + logger.debug("Closing ... removing cluster state listener"); + clusterService.removeListener(clusterStateListener); + } + + private void logChangedAliasState(AliasOrIndex aliasInfo) { + if (aliasInfo == null) { + logger.warn("service provider index/alias [{}] no longer exists", ALIAS_NAME); + } else if (aliasInfo.isAlias() == false) { + logger.warn("service provider index [{}] exists as a concrete index, but it should be an alias", ALIAS_NAME); + } else if (aliasInfo.getIndices().size() != 1) { + logger.warn("service provider alias [{}] refers to multiple indices [{}] - this is unexpected and is likely to cause problems", + ALIAS_NAME, Strings.collectionToCommaDelimitedString(aliasInfo.getIndices())); + } else { + logger.info("service provider alias [{}] refers to [{}]", ALIAS_NAME, aliasInfo.getIndices().get(0).getIndex()); + } + } + + public void installIndexTemplate(ActionListener listener) { + final ClusterState state = clusterService.state(); + if (TemplateUtils.checkTemplateExistsAndIsUpToDate(TEMPLATE_NAME, TEMPLATE_META_VERSION_KEY, state, logger)) { + listener.onResponse(false); + } + final String template = TemplateUtils.loadTemplate(TEMPLATE_RESOURCE, Version.CURRENT.toString(), TEMPLATE_VERSION_SUBSTITUTE); + final PutIndexTemplateRequest request = new PutIndexTemplateRequest(TEMPLATE_NAME).source(template, XContentType.JSON); + client.admin().indices().putTemplate(request, ActionListener.wrap( + response -> listener.onResponse(response.isAcknowledged()), listener::onFailure)); + } + + public void writeDocument(SamlServiceProviderDocument document, ActionListener listener) { + final ValidationException exception = document.validate(); + if (exception != null) { + listener.onFailure(exception); + return; + } + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + XContentBuilder xContentBuilder = new XContentBuilder(XContentType.JSON.xContent(), out)) { + document.toXContent(xContentBuilder, ToXContent.EMPTY_PARAMS); + // Due to the lack of "alias templates" (at the current time), we cannot write to the alias if it doesn't exist yet + // - that would cause the alias to be created as a concrete index, which is not what we want. + // So, until we know that the alias exists we have to write to the expected index name instead. + final IndexRequest request = new IndexRequest(aliasExists ? ALIAS_NAME : INDEX_NAME) + .source(xContentBuilder) + .id(document.docId); + client.index(request, ActionListener.wrap(response -> { + logger.debug("Wrote service provider [{}][{}] as document [{}]", document.name, document.entityId, response.getId()); + listener.onResponse(response.getId()); + }, listener::onFailure)); + } catch (IOException e) { + listener.onFailure(e); + } + } + + public void readDocument(String documentId, ActionListener listener) { + final GetRequest request = new GetRequest(ALIAS_NAME, documentId); + client.get(request, ActionListener.wrap(response -> { + final SamlServiceProviderDocument document = toDocument(documentId, response.getSourceAsBytesRef()); + listener.onResponse(document); + }, listener::onFailure)); + } + + public void findByEntityId(String entityId, ActionListener> listener) { + final QueryBuilder query = QueryBuilders.termQuery(SamlServiceProviderDocument.Fields.ENTITY_ID.getPreferredName(), entityId); + findDocuments(query, listener); + } + + public void findAll(ActionListener> listener) { + final QueryBuilder query = QueryBuilders.matchAllQuery(); + findDocuments(query, listener); + } + + public void refresh(ActionListener listener) { + client.admin().indices().refresh(new RefreshRequest(ALIAS_NAME), ActionListener.wrap( + response -> listener.onResponse(null), listener::onFailure)); + } + + private void findDocuments(QueryBuilder query, ActionListener> listener) { + logger.trace("Searching [{}] for [{}]", ALIAS_NAME, query); + final SearchRequest request = client.prepareSearch(ALIAS_NAME) + .setQuery(query) + .setSize(1000) + .setFetchSource(true) + .request(); + client.search(request, ActionListener.wrap(response -> { + logger.trace("Search hits: [{}] [{}]", response.getHits().getTotalHits(), Arrays.toString(response.getHits().getHits())); + final Set docs = Stream.of(response.getHits().getHits()) + .map(hit -> toDocument(hit.getId(), hit.getSourceRef())) + .collect(Collectors.toUnmodifiableSet()); + listener.onResponse(docs); + }, listener::onFailure)); + } + + private SamlServiceProviderDocument toDocument(String documentId, BytesReference source) { + try (StreamInput in = source.streamInput(); + XContentParser parser = XContentType.JSON.xContent().createParser( + NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, in)) { + return SamlServiceProviderDocument.fromXContent(documentId, parser); + } catch (IOException e) { + throw new UncheckedIOException("failed to parse document [" + documentId + "]", e); + } + } +} diff --git a/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json b/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json new file mode 100644 index 0000000000000..65802025f8e13 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json @@ -0,0 +1,91 @@ +{ + "index_patterns": [ + "saml-service-provider-*" + ], + "aliases": { + "saml-service-provider": {} + }, + "order": 100, + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "auto_expand_replicas": "0-1", + "index.priority": 10, + "index.refresh_interval": "1s", + "index.format": 1 + }, + "mappings": { + "_doc": { + "_meta": { + "idp-version": "${idp.template.version}" + }, + "dynamic": "strict", + "properties": { + "name": { + "type": "text" + }, + "entity_id": { + "type": "keyword" + }, + "acs": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "created": { + "type": "date", + "format": "epoch_millis" + }, + "last_modified": { + "type": "date", + "format": "epoch_millis" + }, + "name_id_format": { + "type": "keyword" + }, + "authn_expiry_ms": { + "type": "long" + }, + "signing_cert": { + "type": "text" + }, + "privileges": { + "type": "object", + "properties": { + "application": { + "type": "keyword" + }, + "resource": { + "type": "keyword" + }, + "login": { + "type": "keyword" + }, + "groups": { + "type": "object", + "dynamic": false + } + } + }, + "attributes": { + "type": "object", + "properties": { + "principal": { + "type": "keyword" + }, + "email": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "groups": { + "type": "keyword" + } + } + } + } + } + } +} diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java new file mode 100644 index 0000000000000..8edbde4bd912e --- /dev/null +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.idp.saml.sp; + +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.xcontent.DeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.xpack.idp.saml.test.IdpSamlTestCase; +import org.hamcrest.Matchers; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.security.x509.X509Credential; + +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class SamlServiceProviderDocumentTests extends IdpSamlTestCase { + + public void testValidationFailuresForMissingFields() throws Exception { + final SamlServiceProviderDocument doc = new SamlServiceProviderDocument(); + doc.setDocId(randomAlphaOfLength(16)); + final ValidationException validationException = doc.validate(); + assertThat(validationException, notNullValue()); + assertThat(validationException.validationErrors(), not(emptyIterable())); + assertThat(validationException.validationErrors(), Matchers.containsInAnyOrder( + "field [name] is required, but was [null]", + "field [entity_id] is required, but was [null]", + "field [acs] is required, but was [null]", + "field [created] is required, but was [null]", + "field [last_modified] is required, but was [null]", + "field [privileges.resource] is required, but was [null]", + "field [attributes.principal] is required, but was [null]" + )); + } + + public void testValidationSucceedsWithMinimalFields() throws Exception { + final SamlServiceProviderDocument doc = new SamlServiceProviderDocument(); + doc.setDocId(randomAlphaOfLength(16)); + doc.setName(randomAlphaOfLengthBetween(8, 12)); + doc.setEntityId("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + doc.setAcs("https://" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8) + "/saml/acs"); + doc.setCreatedMillis(System.currentTimeMillis() - randomIntBetween(100_000, 1_000_000)); + doc.setLastModifiedMillis(System.currentTimeMillis() - randomIntBetween(1_000, 100_000)); + doc.privileges.setResource("service:" + randomAlphaOfLength(12) + ":" + randomAlphaOfLength(12)); + doc.attributeNames.setPrincipal("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + final ValidationException validationException = doc.validate(); + assertThat(validationException, nullValue()); + } + + public void testXContentRoundTripWithMinimalFields() throws Exception { + final SamlServiceProviderDocument doc1 = new SamlServiceProviderDocument(); + doc1.setDocId(randomAlphaOfLength(16)); + doc1.setName(randomAlphaOfLengthBetween(8, 12)); + doc1.setEntityId("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + doc1.setAcs("https://" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8) + "/saml/acs"); + doc1.setCreatedMillis(System.currentTimeMillis() - randomIntBetween(100_000, 1_000_000)); + doc1.setLastModifiedMillis(System.currentTimeMillis() - randomIntBetween(1_000, 100_000)); + doc1.privileges.setResource("service:" + randomAlphaOfLength(12) + ":" + randomAlphaOfLength(12)); + doc1.attributeNames.setPrincipal("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + + final SamlServiceProviderDocument doc2 = assertXContentRoundTrip(doc1); + assertThat(assertXContentRoundTrip(doc2), equalTo(doc1)); + } + + public void testXContentRoundTripWithAllFields() throws Exception { + final List credentials = readCredentials(); + final List certificates = randomSubsetOf(randomIntBetween(1, credentials.size()), credentials).stream() + .map(X509Credential::getEntityCertificate) + .collect(Collectors.toUnmodifiableList()); + final SamlServiceProviderDocument doc1 = new SamlServiceProviderDocument(); + doc1.setDocId(randomAlphaOfLength(16)); + doc1.setName(randomAlphaOfLengthBetween(8, 12)); + doc1.setEntityId("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + doc1.setAcs("https://" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8) + "/saml/acs"); + doc1.setCreatedMillis(System.currentTimeMillis() - randomIntBetween(100_000, 1_000_000)); + doc1.setLastModifiedMillis(System.currentTimeMillis() - randomIntBetween(1_000, 100_000)); + doc1.setNameIdFormat(randomFrom(NameID.TRANSIENT, NameID.PERSISTENT, NameID.EMAIL)); + doc1.setAuthenticationExpiryMillis(randomLongBetween(100, 5_000_000)); + doc1.setX509SigningCertificates(certificates); + + doc1.privileges.setApplication(randomAlphaOfLengthBetween(6, 24)); + doc1.privileges.setResource("service:" + randomAlphaOfLength(12) + ":" + randomAlphaOfLength(12)); + doc1.privileges.setLoginAction(randomAlphaOfLength(6) + ":" + randomAlphaOfLength(6)); + final Map groupActions = new HashMap<>(); + for (int i = randomIntBetween(1, 6); i > 0; i--) { + groupActions.put(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLength(6) + ":" + randomAlphaOfLength(6)); + } + doc1.privileges.setGroupActions(groupActions); + + doc1.attributeNames.setPrincipal("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + doc1.attributeNames.setEmail("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + doc1.attributeNames.setName("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + doc1.attributeNames.setGroups("urn:" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8)); + + final SamlServiceProviderDocument doc2 = assertXContentRoundTrip(doc1); + assertThat(assertXContentRoundTrip(doc2), equalTo(doc1)); + } + + private SamlServiceProviderDocument assertXContentRoundTrip(SamlServiceProviderDocument obj1) throws IOException { + final XContentType xContentType = randomFrom(XContentType.values()); + final boolean humanReadable = randomBoolean(); + final BytesReference bytes1 = XContentHelper.toXContent(obj1, xContentType, humanReadable); + try (XContentParser parser = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, bytes1, xContentType)) { + final SamlServiceProviderDocument obj2 = SamlServiceProviderDocument.fromXContent(obj1.docId, parser); + assertThat(obj2, equalTo(obj1)); + + final BytesReference bytes2 = XContentHelper.toXContent(obj2, xContentType, humanReadable); + assertThat(bytes2, equalTo(bytes1)); + + return obj2; + } + } + +} diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java new file mode 100644 index 0000000000000..80d7595382af4 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.idp.saml.sp; + +import org.elasticsearch.action.admin.indices.create.CreateIndexRequest; +import org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest; +import org.elasticsearch.action.admin.indices.template.delete.DeleteIndexTemplateRequest; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.cluster.metadata.IndexMetaData; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.test.ESSingleNodeTestCase; +import org.elasticsearch.xpack.core.LocalStateCompositeXPackPlugin; +import org.elasticsearch.xpack.idp.IdentityProviderPlugin; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Before; +import org.opensaml.saml.saml2.core.NameID; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.arrayContainingInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.iterableWithSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class SamlServiceProviderIndexTests extends ESSingleNodeTestCase { + + private ClusterService clusterService; + private SamlServiceProviderIndex serviceProviderIndex; + + @Override + protected Collection> getPlugins() { + return List.of(LocalStateCompositeXPackPlugin.class, IdentityProviderPlugin.class); + } + + @Before + public void setupComponents() throws Exception { + clusterService = super.getInstanceFromNode(ClusterService.class); + serviceProviderIndex = new SamlServiceProviderIndex(client(), clusterService); + } + + @After + public void deleteTemplateAndIndex() { + client().admin().indices().delete(new DeleteIndexRequest(SamlServiceProviderIndex.INDEX_NAME + "*")).actionGet(); + client().admin().indices().deleteTemplate(new DeleteIndexTemplateRequest(SamlServiceProviderIndex.TEMPLATE_NAME)).actionGet(); + serviceProviderIndex.close(); + } + + public void testWriteAndFindServiceProvidersFromIndex() { + final int count = randomIntBetween(3, 5); + List documents = new ArrayList<>(count); + + final ClusterService clusterService = super.getInstanceFromNode(ClusterService.class); + // Install the template + assertTrue("Template should have been installed", installTemplate()); + // No need to install it again + assertFalse("Template should not have been installed a second time", installTemplate()); + + // Index should not exist yet + assertThat(clusterService.state().metaData().index(SamlServiceProviderIndex.INDEX_NAME), nullValue()); + + for (int i = 0; i < count; i++) { + final SamlServiceProviderDocument doc = randomDocument(i); + writeDocument(serviceProviderIndex, doc); + documents.add(doc); + } + + final IndexMetaData indexMetaData = clusterService.state().metaData().index(SamlServiceProviderIndex.INDEX_NAME); + assertThat(indexMetaData, notNullValue()); + assertThat(indexMetaData.getSettings().get("index.format"), equalTo("1")); + assertThat(indexMetaData.getAliases().size(), equalTo(1)); + assertThat(indexMetaData.getAliases().keys().toArray(), arrayContainingInAnyOrder(SamlServiceProviderIndex.ALIAS_NAME)); + + refresh(serviceProviderIndex); + + final Set allDocs = getAllDocs(serviceProviderIndex); + assertThat(allDocs, iterableWithSize(count)); + for (SamlServiceProviderDocument doc : documents) { + assertThat(allDocs, hasItem(Matchers.equalTo(doc))); + } + + final SamlServiceProviderDocument readDoc = randomFrom(documents); + assertThat(readDocument(serviceProviderIndex, readDoc.docId), equalTo(readDoc)); + + final SamlServiceProviderDocument findDoc = randomFrom(documents); + assertThat(findByEntityId(serviceProviderIndex, findDoc.entityId), equalTo(findDoc)); + } + + public void testWritesViaAliasIfItExists() { + final PlainActionFuture installTemplate = new PlainActionFuture<>(); + serviceProviderIndex.installIndexTemplate(installTemplate); + assertTrue(installTemplate.actionGet()); + + // Create an index that will trigger the template, but isn't the standard index name + final String customIndexName = SamlServiceProviderIndex.INDEX_NAME + "-test"; + client().admin().indices().create(new CreateIndexRequest(customIndexName)).actionGet(); + + final IndexMetaData indexMetaData = clusterService.state().metaData().index(customIndexName); + assertThat(indexMetaData, notNullValue()); + assertThat(indexMetaData.getSettings().get("index.format"), equalTo("1")); + assertThat(indexMetaData.getAliases().size(), equalTo(1)); + assertThat(indexMetaData.getAliases().keys().toArray(), arrayContainingInAnyOrder(SamlServiceProviderIndex.ALIAS_NAME)); + + SamlServiceProviderDocument document = randomDocument(1); + writeDocument(serviceProviderIndex, document); + + // Index should not exist because we created an alternate index, and the alias points to that. + assertThat(clusterService.state().metaData().index(SamlServiceProviderIndex.INDEX_NAME), nullValue()); + + refresh(serviceProviderIndex); + + final Set allDocs = getAllDocs(serviceProviderIndex); + assertThat(allDocs, iterableWithSize(1)); + assertThat(allDocs, hasItem(Matchers.equalTo(document))); + + assertThat(readDocument(serviceProviderIndex, document.docId), equalTo(document)); + } + + private boolean installTemplate() { + final PlainActionFuture installTemplate = new PlainActionFuture<>(); + serviceProviderIndex.installIndexTemplate(installTemplate); + return installTemplate.actionGet(); + } + + private Set getAllDocs(SamlServiceProviderIndex index) { + final PlainActionFuture> future = new PlainActionFuture<>(); + index.findAll(future); + return future.actionGet(); + } + + private SamlServiceProviderDocument readDocument(SamlServiceProviderIndex index, String docId) { + final PlainActionFuture future = new PlainActionFuture<>(); + index.readDocument(docId, future); + return future.actionGet(); + } + + private void writeDocument(SamlServiceProviderIndex index, SamlServiceProviderDocument doc) { + final PlainActionFuture future = new PlainActionFuture<>(); + index.writeDocument(doc, future); + doc.setDocId(future.actionGet()); + } + + + private SamlServiceProviderDocument findByEntityId(SamlServiceProviderIndex index, String entityId) { + final PlainActionFuture> future = new PlainActionFuture<>(); + index.findByEntityId(entityId, future); + final Set docs = future.actionGet(); + assertThat(docs, iterableWithSize(1)); + return docs.iterator().next(); + } + + private void refresh(SamlServiceProviderIndex index) { + PlainActionFuture future = new PlainActionFuture<>(); + index.refresh(future); + future.actionGet(); + } + + private SamlServiceProviderDocument randomDocument(int index) { + final SamlServiceProviderDocument document = new SamlServiceProviderDocument(); + document.setName(randomAlphaOfLengthBetween(5, 12)); + document.setEntityId(randomUri() + index); + document.setAcs(randomUri("https") + index + "/saml/acs"); + + document.setEnabled(randomBoolean()); + document.setCreatedMillis(System.currentTimeMillis() - TimeValue.timeValueDays(randomIntBetween(2, 90)).millis()); + document.setLastModifiedMillis(System.currentTimeMillis() - TimeValue.timeValueHours(randomIntBetween(1, 36)).millis()); + + if (randomBoolean()) { + document.setNameIdFormat(randomFrom(NameID.TRANSIENT, NameID.PERSISTENT)); + } + if (randomBoolean()) { + document.setAuthenticationExpiryMillis(TimeValue.timeValueMinutes(randomIntBetween(1, 15)).millis()); + } + + document.privileges.setResource("app:" + randomAlphaOfLengthBetween(3, 6) + ":" + Math.abs(randomLong())); + if (randomBoolean()) { + document.privileges.setApplication(randomAlphaOfLengthBetween(4, 12)); + } + if (randomBoolean()) { + document.privileges.setLoginAction(randomAlphaOfLengthBetween(3, 6) + ":" + randomAlphaOfLengthBetween(3, 6)); + } + final int groupCount = randomIntBetween(0, 4); + final Map groups = new HashMap<>(); + for (int i = 0; i < groupCount; i++) { + groups.put(randomAlphaOfLengthBetween(4, 8), randomAlphaOfLengthBetween(3, 6) + ":" + randomAlphaOfLengthBetween(3, 6)); + } + document.privileges.setGroupActions(groups); + + document.attributeNames.setPrincipal(randomUri()); + if (randomBoolean()) { + document.attributeNames.setName(randomUri()); + } + if (randomBoolean()) { + document.attributeNames.setEmail(randomUri()); + } + if (groups.isEmpty() == false) { + document.attributeNames.setGroups(randomUri()); + } + + assertThat(document.validate(), nullValue()); + return document; + } + + private String randomUri() { + return randomUri(randomFrom("urn", "http", "https")); + } + + private String randomUri(String scheme) { + return scheme + "://" + randomAlphaOfLengthBetween(2, 6) + "." + + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(2, 4) + "/"; + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java index 11c57fa3f5d4a..77d8c9d9737bb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java @@ -22,6 +22,7 @@ import static org.elasticsearch.action.admin.cluster.node.tasks.get.GetTaskAction.TASKS_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.ASYNC_SEARCH_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.ENRICH_ORIGIN; +import static org.elasticsearch.xpack.core.ClientHelper.IDP_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.TRANSFORM_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.DEPRECATION_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.INDEX_LIFECYCLE_ORIGIN; @@ -115,6 +116,7 @@ public static void switchUserBasedOnActionOriginAndExecute(ThreadContext threadC case ROLLUP_ORIGIN: case INDEX_LIFECYCLE_ORIGIN: case ENRICH_ORIGIN: + case IDP_ORIGIN: case TASKS_ORIGIN: // TODO use a more limited user for tasks securityContext.executeAsUser(XPackUser.INSTANCE, consumer, Version.CURRENT); break; From ccae3428bc23fbc80c360caf5fbdba1fa89d0b76 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Sat, 22 Feb 2020 00:33:00 +1100 Subject: [PATCH 2/3] Add additional fields --- .../saml/sp/SamlServiceProviderDocument.java | 189 ++++++++++++------ .../index/saml-service-provider-template.json | 17 +- .../sp/SamlServiceProviderDocumentTests.java | 12 +- .../sp/SamlServiceProviderIndexTests.java | 2 +- 4 files changed, 153 insertions(+), 67 deletions(-) diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java index 5e0a47ba56daf..0ac4a065b1370 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -125,6 +126,105 @@ public int hashCode() { } } + public static class Certificates { + public List serviceProviderSigning = List.of(); + public List identityProviderSigning = List.of(); + public List identityProviderMetadataSigning = List.of(); + + public void setServiceProviderSigning(Collection serviceProviderSigning) { + this.serviceProviderSigning = serviceProviderSigning == null ? List.of() : List.copyOf(serviceProviderSigning); + } + + public void setIdentityProviderSigning(Collection identityProviderSigning) { + this.identityProviderSigning = identityProviderSigning == null ? List.of() : List.copyOf(identityProviderSigning); + } + + public void setIdentityProviderMetadataSigning(Collection identityProviderMetadataSigning) { + this.identityProviderMetadataSigning + = identityProviderMetadataSigning == null ? List.of() : List.copyOf(identityProviderMetadataSigning); + } + + public void setServiceProviderX509SigningCertificates(Collection certificates) { + this.serviceProviderSigning = encodeCertificates(certificates); + } + + public List getServiceProviderX509SigningCertificates() { + return decodeCertificates(this.serviceProviderSigning); + } + + public void setIdentityProviderX509SigningCertificates(Collection certificates) { + this.identityProviderSigning = encodeCertificates(certificates); + } + + public List getIdentityProviderX509SigningCertificates() { + return decodeCertificates(this.identityProviderSigning); + } + + public void setIdentityProviderX509MetadataSigningCertificates(Collection certificates) { + this.identityProviderMetadataSigning = encodeCertificates(certificates); + } + + public List getIdentityProviderX509MetadataSigningCertificates() { + return decodeCertificates(this.identityProviderMetadataSigning); + } + + private List encodeCertificates(Collection certificates) { + return certificates == null ? List.of() : certificates.stream() + .map(cert -> { + try { + return cert.getEncoded(); + } catch (CertificateEncodingException e) { + throw new ElasticsearchException("Cannot read certificate", e); + } + }) + .map(Base64.getEncoder()::encodeToString) + .collect(Collectors.toUnmodifiableList()); + } + + private List decodeCertificates(List encodedCertificates) { + if (encodedCertificates == null || encodedCertificates.isEmpty()) { + return List.of(); + } + return encodedCertificates.stream().map(this::decodeCertificate).collect(Collectors.toUnmodifiableList()); + } + + private X509Certificate decodeCertificate(String base64Cert) { + final byte[] bytes = base64Cert.getBytes(StandardCharsets.UTF_8); + try (InputStream stream = new ByteArrayInputStream(bytes)) { + final List certificates = CertParsingUtils.readCertificates(Base64.getDecoder().wrap(stream)); + if (certificates.size() == 1) { + final Certificate certificate = certificates.get(0); + if (certificate instanceof X509Certificate) { + return (X509Certificate) certificate; + } else { + throw new ElasticsearchException("Certificate ({}) is not a X.509 certificate", certificate.getClass()); + } + } else { + throw new ElasticsearchException("Expected a single certificate, but found {}", certificates.size()); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (CertificateException e) { + throw new ElasticsearchException("Cannot parse certificate(s)", e); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Certificates that = (Certificates) o; + return Objects.equals(serviceProviderSigning, that.serviceProviderSigning) && + Objects.equals(identityProviderSigning, that.identityProviderSigning) && + Objects.equals(identityProviderMetadataSigning, that.identityProviderMetadataSigning); + } + + @Override + public int hashCode() { + return Objects.hash(serviceProviderSigning, identityProviderSigning, identityProviderMetadataSigning); + } + } + public String docId; public String name; @@ -138,15 +238,14 @@ public int hashCode() { public Instant lastModified; @Nullable - public String nameIdFormat; + public Set nameIdFormats = Set.of(); @Nullable public Long authenticationExpiryMillis; - public List signingCertificates = List.of(); - public final Privileges privileges = new Privileges(); public final AttributeNames attributeNames = new AttributeNames(); + public final Certificates certificates = new Certificates(); public String getDocId() { return docId; @@ -180,8 +279,8 @@ public void setLastModifiedMillis(Long millis) { this.lastModified = Instant.ofEpochMilli(millis); } - public void setNameIdFormat(String nameIdFormat) { - this.nameIdFormat = nameIdFormat; + public void setNameIdFormats(Collection nameIdFormats) { + this.nameIdFormats = nameIdFormats == null ? Set.of() : Set.copyOf(nameIdFormats); } public void setAuthenticationExpiryMillis(Long authenticationExpiryMillis) { @@ -196,51 +295,6 @@ public ReadableDuration getAuthenticationExpiryMillis() { return Duration.millis(this.authenticationExpiryMillis); } - public void setSigningCertificates(Collection signingCertificates) { - this.signingCertificates = signingCertificates == null ? List.of() : List.copyOf(signingCertificates); - } - - public void setX509SigningCertificates(Collection certificates) throws CertificateEncodingException { - this.signingCertificates = certificates == null ? List.of() : certificates.stream() - .map(cert -> { - try { - return cert.getEncoded(); - } catch (CertificateEncodingException e) { - throw new ElasticsearchException("Cannot read certificate", e); - } - }) - .map(Base64.getEncoder()::encodeToString) - .collect(Collectors.toUnmodifiableList()); - } - - public List getX509SigningCertificates() { - if (this.signingCertificates == null || this.signingCertificates.isEmpty()) { - return List.of(); - } - return this.signingCertificates.stream().map(this::toX509Certificate).collect(Collectors.toUnmodifiableList()); - } - - private X509Certificate toX509Certificate(String base64Cert) { - final byte[] bytes = base64Cert.getBytes(StandardCharsets.UTF_8); - try (InputStream stream = new ByteArrayInputStream(bytes)) { - final List certificates = CertParsingUtils.readCertificates(Base64.getDecoder().wrap(stream)); - if (certificates.size() == 1) { - final Certificate certificate = certificates.get(0); - if (certificate instanceof X509Certificate) { - return (X509Certificate) certificate; - } else { - throw new ElasticsearchException("Certificate ({}) is not a X.509 certificate", certificate.getClass()); - } - } else { - throw new ElasticsearchException("Expected a single certificate, but found {}", certificates.size()); - } - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (CertificateException e) { - throw new ElasticsearchException("Cannot parse certificate(s)", e); - } - } - @Override public boolean equals(Object o) { if (this == o) return true; @@ -253,23 +307,24 @@ public boolean equals(Object o) { Objects.equals(enabled, that.enabled) && Objects.equals(created, that.created) && Objects.equals(lastModified, that.lastModified) && - Objects.equals(nameIdFormat, that.nameIdFormat) && + Objects.equals(nameIdFormats, that.nameIdFormats) && Objects.equals(authenticationExpiryMillis, that.authenticationExpiryMillis) && - Objects.equals(signingCertificates, that.signingCertificates) && + Objects.equals(certificates, that.certificates) && Objects.equals(privileges, that.privileges) && Objects.equals(attributeNames, that.attributeNames); } @Override public int hashCode() { - return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormat, authenticationExpiryMillis, - signingCertificates, privileges, attributeNames); + return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormats, authenticationExpiryMillis, + certificates, privileges, attributeNames); } private static final ObjectParser DOC_PARSER = new ObjectParser<>("service_provider_doc", true, SamlServiceProviderDocument::new); private static final ObjectParser PRIVILEGES_PARSER = new ObjectParser<>("service_provider_priv", true, null); private static final ObjectParser ATTRIBUTES_PARSER = new ObjectParser<>("service_provider_attr", true, null); + private static final ObjectParser CERTIFICATES_PARSER = new ObjectParser<>("service_provider_cert", true, null); private static final BiConsumer NULL_CONSUMER = (doc, obj) -> { }; @@ -281,12 +336,10 @@ public int hashCode() { DOC_PARSER.declareBoolean(SamlServiceProviderDocument::setEnabled, Fields.ENABLED); DOC_PARSER.declareLong(SamlServiceProviderDocument::setCreatedMillis, Fields.CREATED_DATE); DOC_PARSER.declareLong(SamlServiceProviderDocument::setLastModifiedMillis, Fields.LAST_MODIFIED); - DOC_PARSER.declareStringOrNull(SamlServiceProviderDocument::setNameIdFormat, Fields.NAME_ID); - // Using a method reference here angers some compilers + DOC_PARSER.declareStringArray(SamlServiceProviderDocument::setNameIdFormats, Fields.NAME_ID); DOC_PARSER.declareField(SamlServiceProviderDocument::setAuthenticationExpiryMillis, parser -> parser.currentToken() == XContentParser.Token.VALUE_NULL ? null : parser.longValue(), Fields.AUTHN_EXPIRY, ObjectParser.ValueType.LONG_OR_NULL); - DOC_PARSER.declareStringArray(SamlServiceProviderDocument::setSigningCertificates, Fields.SIGNING_CERT); DOC_PARSER.declareObject(NULL_CONSUMER, (parser, doc) -> PRIVILEGES_PARSER.parse(parser, doc.privileges, null), Fields.PRIVILEGES); PRIVILEGES_PARSER.declareStringOrNull(Privileges::setApplication, Fields.Privileges.APPLICATION); @@ -301,6 +354,11 @@ public int hashCode() { ATTRIBUTES_PARSER.declareStringOrNull(AttributeNames::setEmail, Fields.Attributes.EMAIL); ATTRIBUTES_PARSER.declareStringOrNull(AttributeNames::setName, Fields.Attributes.NAME); ATTRIBUTES_PARSER.declareStringOrNull(AttributeNames::setGroups, Fields.Attributes.GROUPS); + + DOC_PARSER.declareObject(NULL_CONSUMER, (p, doc) -> CERTIFICATES_PARSER.parse(p, doc.certificates, null), Fields.CERTIFICATES); + CERTIFICATES_PARSER.declareStringArray(Certificates::setServiceProviderSigning, Fields.Certificates.SP_SIGNING); + CERTIFICATES_PARSER.declareStringArray(Certificates::setIdentityProviderSigning, Fields.Certificates.IDP_SIGNING); + CERTIFICATES_PARSER.declareStringArray(Certificates::setIdentityProviderMetadataSigning, Fields.Certificates.IDP_METADATA); } public static SamlServiceProviderDocument fromXContent(String docId, XContentParser parser) throws IOException { @@ -351,9 +409,8 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field(Fields.ENABLED.getPreferredName(), enabled); builder.field(Fields.CREATED_DATE.getPreferredName(), created == null ? null : created.toEpochMilli()); builder.field(Fields.LAST_MODIFIED.getPreferredName(), lastModified == null ? null : lastModified.toEpochMilli()); - builder.field(Fields.NAME_ID.getPreferredName(), nameIdFormat); + builder.field(Fields.NAME_ID.getPreferredName(), nameIdFormats == null ? List.of() : nameIdFormats); builder.field(Fields.AUTHN_EXPIRY.getPreferredName(), authenticationExpiryMillis); - builder.field(Fields.SIGNING_CERT.getPreferredName(), signingCertificates == null ? List.of() : signingCertificates); builder.startObject(Fields.PRIVILEGES.getPreferredName()); builder.field(Fields.Privileges.APPLICATION.getPreferredName(), privileges.application); @@ -369,6 +426,12 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field(Fields.Attributes.GROUPS.getPreferredName(), attributeNames.groups); builder.endObject(); + builder.startObject(Fields.CERTIFICATES.getPreferredName()); + builder.field(Fields.Certificates.SP_SIGNING.getPreferredName(), certificates.serviceProviderSigning); + builder.field(Fields.Certificates.IDP_SIGNING.getPreferredName(), certificates.identityProviderSigning); + builder.field(Fields.Certificates.IDP_METADATA.getPreferredName(), certificates.identityProviderMetadataSigning); + builder.endObject(); + return builder.endObject(); } @@ -379,13 +442,13 @@ interface Fields { ParseField ENABLED = new ParseField("enabled"); ParseField NAME_ID = new ParseField("name_id_format"); ParseField AUTHN_EXPIRY = new ParseField("authn_expiry_ms"); - ParseField SIGNING_CERT = new ParseField("signing_cert"); ParseField CREATED_DATE = new ParseField("created"); ParseField LAST_MODIFIED = new ParseField("last_modified"); ParseField PRIVILEGES = new ParseField("privileges"); ParseField ATTRIBUTES = new ParseField("attributes"); + ParseField CERTIFICATES = new ParseField("certificates"); interface Privileges { ParseField APPLICATION = new ParseField("application"); @@ -400,5 +463,11 @@ interface Attributes { ParseField NAME = new ParseField("name"); ParseField GROUPS = new ParseField("groups"); } + + interface Certificates { + ParseField SP_SIGNING = new ParseField("sp_signing"); + ParseField IDP_SIGNING = new ParseField("idp_signing"); + ParseField IDP_METADATA = new ParseField("idp_metadata"); + } } } diff --git a/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json b/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json index 65802025f8e13..de7bcdbcb564f 100644 --- a/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json +++ b/x-pack/plugin/identity-provider/src/main/resources/index/saml-service-provider-template.json @@ -47,9 +47,6 @@ "authn_expiry_ms": { "type": "long" }, - "signing_cert": { - "type": "text" - }, "privileges": { "type": "object", "properties": { @@ -84,6 +81,20 @@ "type": "keyword" } } + }, + "certificates": { + "type": "object", + "properties": { + "sp_signing": { + "type": "text" + }, + "idp_signing": { + "type": "text" + }, + "idp_metadata": { + "type": "text" + } + } } } } diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java index 8edbde4bd912e..acc0134d98467 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocumentTests.java @@ -81,9 +81,13 @@ public void testXContentRoundTripWithMinimalFields() throws Exception { public void testXContentRoundTripWithAllFields() throws Exception { final List credentials = readCredentials(); - final List certificates = randomSubsetOf(randomIntBetween(1, credentials.size()), credentials).stream() + final List certificates = credentials.stream() .map(X509Credential::getEntityCertificate) .collect(Collectors.toUnmodifiableList()); + final List spCertificates = randomSubsetOf(certificates); + final List idpCertificates = randomSubsetOf(certificates); + final List idpMetadataCertificates = randomSubsetOf(certificates); + final SamlServiceProviderDocument doc1 = new SamlServiceProviderDocument(); doc1.setDocId(randomAlphaOfLength(16)); doc1.setName(randomAlphaOfLengthBetween(8, 12)); @@ -91,9 +95,11 @@ public void testXContentRoundTripWithAllFields() throws Exception { doc1.setAcs("https://" + randomAlphaOfLengthBetween(4, 8) + "." + randomAlphaOfLengthBetween(4, 8) + "/saml/acs"); doc1.setCreatedMillis(System.currentTimeMillis() - randomIntBetween(100_000, 1_000_000)); doc1.setLastModifiedMillis(System.currentTimeMillis() - randomIntBetween(1_000, 100_000)); - doc1.setNameIdFormat(randomFrom(NameID.TRANSIENT, NameID.PERSISTENT, NameID.EMAIL)); + doc1.setNameIdFormats(randomSubsetOf(List.of(NameID.TRANSIENT, NameID.PERSISTENT, NameID.EMAIL))); doc1.setAuthenticationExpiryMillis(randomLongBetween(100, 5_000_000)); - doc1.setX509SigningCertificates(certificates); + doc1.certificates.setServiceProviderX509SigningCertificates(spCertificates); + doc1.certificates.setIdentityProviderX509SigningCertificates(idpCertificates); + doc1.certificates.setIdentityProviderX509MetadataSigningCertificates(idpMetadataCertificates); doc1.privileges.setApplication(randomAlphaOfLengthBetween(6, 24)); doc1.privileges.setResource("service:" + randomAlphaOfLength(12) + ":" + randomAlphaOfLength(12)); diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java index 80d7595382af4..c7f2298d00616 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndexTests.java @@ -179,7 +179,7 @@ private SamlServiceProviderDocument randomDocument(int index) { document.setLastModifiedMillis(System.currentTimeMillis() - TimeValue.timeValueHours(randomIntBetween(1, 36)).millis()); if (randomBoolean()) { - document.setNameIdFormat(randomFrom(NameID.TRANSIENT, NameID.PERSISTENT)); + document.setNameIdFormats(randomSubsetOf(List.of(NameID.TRANSIENT, NameID.PERSISTENT))); } if (randomBoolean()) { document.setAuthenticationExpiryMillis(TimeValue.timeValueMinutes(randomIntBetween(1, 15)).millis()); From 45df122f589c51c5661f5dc3fa1d2a5bd20886fa Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Sun, 23 Feb 2020 23:43:22 +1100 Subject: [PATCH 3/3] Fix template due to #51765 --- .../xpack/idp/saml/sp/SamlServiceProviderIndex.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java index 13d23623a32e8..ab23a16098486 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderIndex.java @@ -43,7 +43,6 @@ import java.io.UncheckedIOException; import java.util.Arrays; import java.util.Set; -import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -64,7 +63,7 @@ public class SamlServiceProviderIndex implements Closeable { private static final String TEMPLATE_RESOURCE = "/index/saml-service-provider-template.json"; private static final String TEMPLATE_META_VERSION_KEY = "idp-version"; - private static final String TEMPLATE_VERSION_SUBSTITUTE = Pattern.quote("${idp.template.version}"); + private static final String TEMPLATE_VERSION_SUBSTITUTE = "idp.template.version"; private final ClusterStateListener clusterStateListener; public SamlServiceProviderIndex(Client client, ClusterService clusterService) {