diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index 4bc2151c2498..edcbe0d3095a 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -29,13 +29,13 @@ To use token-based authentication, add `token_processors` section to `config.xml Its contents are different for different token processor types. **Common parameters** -- `type` -- type of token processor. Supported values: `jwt_static_key`, `jwt_static_jwks`, `jwt_dynamic_jwks`, `entra` (`azure` is accepted as a back-compat alias and resolves to the same `entra` processor — see the [Entra](#entra) section), `openid`. Mandatory. Case-insensitive. -- `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600. +- `type` -- type of token processor. Supported values: `jwt_static_key`, `jwt_static_jwks`, `jwt_dynamic_jwks`, `aws_sso`, `entra` (`azure` is accepted as a back-compat alias and resolves to the same `entra` processor — see the [Entra](#entra) section), `openid`. Mandatory. Case-insensitive. +- `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600 (60 for `aws_sso`). - `username_claim` -- name of claim (field) that will be treated as ClickHouse username. Optional, default: "sub". - `groups_claim` -- name of claim (field) that contains list of groups user belongs to. This claim will be looked up in the token itself (in case token is a valid JWT, e.g. in Keycloak) or in response from `/userinfo`. Optional, default: "groups". For each type, there are additional specific parameters (some of them are mandatory). -If some parameters that are not required for current processor type are specified, they are ignored. +Unsupported parameters may be rejected; see the rules for each processor type. ## JWT (JSON Web Token) @@ -131,12 +131,56 @@ For JWKS-based validators (`jwt_static_jwks` and `jwt_dynamic_jwks`), RS* and ES ## IdP-specific presets and generic external providers -This section covers two related kinds of processor: per-IdP convenience presets built on top of the generic JWT processors (currently `entra`), and the generic `openid` processor that talks to an arbitrary OIDC-compliant identity provider. +This section covers per-IdP presets built on the generic JWT processors (`entra`), direct AWS IAM Identity Center validation (`aws_sso`), and the generic `openid` processor for OIDC-compliant identity providers. :::note If the IdP issues access tokens that follow [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) (the *JSON Web Token Profile for OAuth 2.0 Access Tokens*), the access token is itself a verifiable JWT and is best handled by one of the JWT processors above (typically `jwt_dynamic_jwks`) — no `/userinfo` or `/tokeninfo` round-trip is needed. The processors in this section exist for IdPs whose access tokens are opaque (e.g. Google), or whose JWT access tokens you prefer to validate by asking the IdP rather than locally. ::: +### AWS IAM Identity Center (AWS SSO) {#aws-sso} + +`aws_sso` validates IAM Identity Center access tokens through AWS `GetRoleCredentials`, then signs an STS `GetCallerIdentity` request with the returned temporary credentials. It checks the account and permission-set role and uses the full STS assumed-role ARN as the ClickHouse username. The server does not require its own AWS credentials. + +```xml + + + + aws_sso + eu-central-1 + 123456789012 + ClickHouseAccess + d-1234567890 + 60 + + + +``` + +**Parameters:** + +- `region` — Region hosting IAM Identity Center, such as `eu-central-1`. Mandatory. +- `account_id` — The 12-digit AWS account assigned to the user. Mandatory. +- `role_name` — Assigned permission-set name, such as `ClickHouseAccess`. Use the name reported by AWS `ListAccountRoles`, not the generated `AWSReservedSSO_...` IAM role name or an ARN. Mandatory. +- `identity_store_id` — IAM Identity Center Identity Store ID, such as `d-1234567890`. When set, the processor resolves the AWS-issued role-session name with Identity Store `GetUserId`, loads all direct memberships with `ListGroupMembershipsForMember`, and exposes their stable `GroupId` values to the external user directory. Optional. +- `token_cache_lifetime` — Maximum validation-cache and authenticated-session lifetime in seconds, capped by the returned role-credential expiry. Optional, default: 60. Range: 1–3600. Cached validations can remain usable until this deadline after access-token expiry, revocation, or assignment removal. + +The processor rejects `username_claim`, `groups_claim`, JWT claim restrictions, issuer/audience settings, and OIDC/JWKS endpoint settings. Without `identity_store_id`, no external groups are returned; use `common_roles` in the [external user directory](#idp-external-user-directory) to assign ClickHouse permissions. + +With `identity_store_id`, grant the configured permission set `identitystore:GetUserId` and `identitystore:ListGroupMembershipsForMember`. The lookup is signed with the user's short-lived role credentials, so the ClickHouse server still needs no AWS credentials of its own. Group lookup fails closed: an Identity Store error rejects authentication rather than creating a session without roles. Use the returned group IDs as the `from` values in [`roles_mapping`](#idp-external-user-directory), for example: + +```xml + + + 1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + ch_analyst + + +``` + +Obtain an IAM Identity Center access token with the `sso:account:access` scope for the configured region and an AWS account assignment. Pass it using `Authorization: Bearer` or `clickhouse-client --jwt`; the caller handles acquisition and refresh. Such tokens can obtain credentials for the user's AWS account assignments and cannot be restricted to a ClickHouse-specific audience by this processor; send them only to trusted servers over TLS. + +If `remote_url_allow_hosts` is configured, allow `portal.sso..amazonaws.com`, `sts..amazonaws.com`, and, when `identity_store_id` is set, `identitystore..amazonaws.com` (use `amazonaws.com.cn` for China regions). Endpoints are derived from the region. The build requires JWT, SSL, and AWS SDK support. + ### Entra (Microsoft Entra ID, pure OIDC) {#entra} `entra` is a preset for Microsoft Entra ID built on top of `jwt_dynamic_jwks`. Tokens are validated **locally** against Entra's per-tenant JWKS — no Microsoft Graph call, no userinfo round trip, no OIDC discovery fetch. `username_claim` and `groups_claim` are read directly from the JWT payload. Use this when the access token's `aud` is your own app (registered via Entra's *Expose an API* blade), not `https://graph.microsoft.com`. diff --git a/src/Access/AwsSSOTokenProcessor.cpp b/src/Access/AwsSSOTokenProcessor.cpp new file mode 100644 index 000000000000..1b43b3232c58 --- /dev/null +++ b/src/Access/AwsSSOTokenProcessor.cpp @@ -0,0 +1,391 @@ +#include + +#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace DB +{ +namespace ErrorCodes +{ + extern const int AUTHENTICATION_FAILED; + extern const int INVALID_CONFIG_PARAMETER; +} + +namespace +{ +constexpr size_t max_response_size = 64 * 1024; +constexpr size_t max_group_pages = 100; + +bool isSafeHeader(const String & value) +{ + return !value.empty() && value.size() <= max_response_size + && std::ranges::all_of(value, [](unsigned char c) + { + return c > 32 && c < 127; + }); +} + +const picojson::value & requiredField(const picojson::value & object, const char * name) +{ + if (!object.is() || !object.contains(name)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response is missing field '{}'", name); + return object.get(name); +} + +String requiredString(const picojson::value & object, const char * name) +{ + const auto & value = requiredField(object, name); + if (!value.is() || !isSafeHeader(value.get())) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response has an invalid '{}' field", name); + return value.get(); +} + +String xmlField(const Aws::Utils::Xml::XmlNode & parent, const char * name) +{ + auto node = parent.FirstChild(name); + if (node.IsNull() || !node.NextNode(name).IsNull() || !node.FirstChild().IsNull()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS STS response has an invalid '{}' field", name); + const auto value = Aws::Utils::Xml::DecodeEscapedXmlText(node.GetText()); + if (!isSafeHeader(value)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS STS response has an empty or invalid '{}' field", name); + return value; +} + +bool isHex(char c) +{ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); +} + +bool isValidIdentityStoreId(const String & value) +{ + if (value.size() == 12 && value.starts_with("d-")) + return std::ranges::all_of(value.begin() + 2, value.end(), isHex); + if (value.size() != 36) + return false; + for (size_t i = 0; i < value.size(); ++i) + { + if (i == 8 || i == 13 || i == 18 || i == 23) + { + if (value[i] != '-') + return false; + } + else if (!isHex(value[i])) + return false; + } + return true; +} +} + +namespace AwsSSO +{ + +String parseUserId(const String & response) +{ + picojson::value json; + if (!parseWholeJSON(json, response).empty()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS Identity Store JSON response"); + return requiredString(json, "UserId"); +} + +GroupMembershipsPage parseGroupMemberships(const String & response) +{ + picojson::value json; + if (!parseWholeJSON(json, response).empty()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS Identity Store JSON response"); + + const auto & memberships = requiredField(json, "GroupMemberships"); + if (!memberships.is()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store response has an invalid 'GroupMemberships' field"); + + GroupMembershipsPage page; + for (const auto & membership : memberships.get()) + page.group_ids.insert(requiredString(membership, "GroupId")); + + if (json.contains("NextToken")) + { + const auto & next_token = json.get("NextToken"); + if (!next_token.is() || (!next_token.get().empty() && !isSafeHeader(next_token.get()))) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store response has an invalid 'NextToken' field"); + page.next_token = next_token.get(); + } + return page; +} + +} + +AwsSSOTokenProcessor::AwsSSOTokenProcessor( + const String & name, UInt64 cache_lifetime, const String & region_, const String & account_id_, + const String & role_name_, const String & identity_store_id_, const ConnectionTimeouts & timeouts_) + : ITokenProcessor(name, cache_lifetime) + , region(region_) + , account_id(account_id_) + , role_name(role_name_) + , identity_store_id(identity_store_id_) + , partition(region.starts_with("cn-") ? "aws-cn" : region.starts_with("us-gov-") ? "aws-us-gov" : "aws") + , domain(region.starts_with("cn-") ? "amazonaws.com.cn" : "amazonaws.com") + , timeouts(timeouts_) +{ + if (region.empty() || region.size() > 32 || region.front() == '-' || region.back() < '0' || region.back() > '9' + || !std::ranges::all_of(region, [](char c) + { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; + }) + || region.starts_with("us-iso")) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a valid commercial, China, or GovCloud region"); + if (account_id.size() != 12 || !std::ranges::all_of(account_id, [](char c) + { + return c >= '0' && c <= '9'; + })) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a 12-digit account_id"); + if (role_name.empty() || role_name.size() > 32 || !std::ranges::all_of(role_name, [](unsigned char c) + { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("_+=,.@-").contains(c); + })) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a permission-set role_name (1 to 32 characters)"); + if (!identity_store_id.empty() && !isValidIdentityStoreId(identity_store_id)) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a valid identity_store_id"); + if (cache_lifetime == 0 || cache_lifetime > 3600) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO token_cache_lifetime must be between 1 and 3600 seconds"); +} + +String AwsSSOTokenProcessor::getPortalEndpoint() const +{ + return "https://portal.sso." + region + "." + domain; +} + +String AwsSSOTokenProcessor::getSTSEndpoint() const +{ + return "https://sts." + region + "." + domain + "/?Action=GetCallerIdentity&Version=2011-06-15"; +} + +String AwsSSOTokenProcessor::getIdentityStoreEndpoint() const +{ + return "https://identitystore." + region + "." + domain + "/"; +} + +AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request( + const String & method, const String & url, const Headers & headers, const String & request_body) const +{ + Response result; + const Poco::URI uri(url); + /// Authentication must not inherit permissive server TLS settings. + Poco::Net::Context::Ptr tls_context = new Poco::Net::Context( + Poco::Net::Context::CLIENT_USE, "", Poco::Net::Context::VERIFY_STRICT, 9, true); + tls_context->enableExtendedCertificateVerification(); + SSL_CTX_set_verify(tls_context->sslContext(), SSL_VERIFY_PEER, nullptr); + Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort(), tls_context); + setTimeouts(session, timeouts); + Poco::Net::HTTPRequest http_request(method, uri.getPathAndQuery()); + for (const auto & [name, value] : headers) + http_request.set(name, value); + if (!request_body.empty()) + http_request.setContentLength(request_body.size()); + auto & output = session.sendRequest(http_request); + if (!request_body.empty()) + { + output.write(request_body.data(), request_body.size()); + output.flush(); + } + Poco::Net::HTTPResponse response; + auto & body = session.receiveResponse(response); + result.status = response.getStatus(); + if (result.status == 200) + { + std::array buffer; + while (body.read(buffer.data(), buffer.size()) || body.gcount()) + { + if (result.body.size() + body.gcount() > max_response_size) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit"); + result.body.append(buffer.data(), body.gcount()); + } + if (body.bad()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot read AWS SSO response"); + } + if (result.body.size() > max_response_size) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit"); + /// Response bodies may contain AWS credentials; never include them in errors. + if (result.status != 200 && result.status != 401 && result.status != 403 && result.status != 404) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO validation failed with HTTP status {}", result.status); + return result; +} + +String AwsSSOTokenProcessor::identityStoreRequest( + const String & target, const String & body, const std::shared_ptr & provider) const +{ + Aws::Client::AWSAuthV4Signer signer(provider, "identitystore", region); + Aws::Http::Standard::StandardHttpRequest aws_request( + Aws::Http::URI(getIdentityStoreEndpoint()), Aws::Http::HttpMethod::HTTP_POST); + aws_request.SetHeaderValue("content-type", "application/x-amz-json-1.1"); + aws_request.SetHeaderValue("x-amz-target", target.c_str()); + auto body_stream = Aws::MakeShared("AwsSSOTokenProcessor"); + body_stream->write(body.data(), body.size()); + body_stream->seekg(0); + aws_request.AddContentBody(body_stream); + if (!signer.SignRequest(aws_request, region.c_str(), "identitystore", true)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot sign AWS Identity Store request"); + + Headers headers; + for (const auto & [name, value] : aws_request.GetHeaders()) + headers.emplace_back(name, value); + const auto response = request(Poco::Net::HTTPRequest::HTTP_POST, getIdentityStoreEndpoint(), headers, body); + if (response.status != 200) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store request failed with HTTP status {}", response.status); + return response.body; +} + +std::set AwsSSOTokenProcessor::getGroupIds( + const String & user_name, const std::shared_ptr & provider) const +{ + Aws::Utils::Json::JsonValue unique_attribute; + unique_attribute.WithString("AttributePath", "userName"); + unique_attribute.WithString("AttributeValue", user_name.c_str()); + Aws::Utils::Json::JsonValue alternate_identifier; + alternate_identifier.WithObject("UniqueAttribute", std::move(unique_attribute)); + Aws::Utils::Json::JsonValue user_request; + user_request.WithString("IdentityStoreId", identity_store_id.c_str()); + user_request.WithObject("AlternateIdentifier", std::move(alternate_identifier)); + const auto user_request_body = user_request.View().WriteCompact(); + const auto user_id = AwsSSO::parseUserId( + identityStoreRequest("AWSIdentityStore.GetUserId", String(user_request_body.c_str(), user_request_body.size()), provider)); + + std::set group_ids; + String next_token; + for (size_t page_number = 0; page_number < max_group_pages; ++page_number) + { + Aws::Utils::Json::JsonValue member_id; + member_id.WithString("UserId", user_id.c_str()); + Aws::Utils::Json::JsonValue groups_request; + groups_request.WithString("IdentityStoreId", identity_store_id.c_str()); + groups_request.WithObject("MemberId", std::move(member_id)); + groups_request.WithInteger("MaxResults", 100); + if (!next_token.empty()) + groups_request.WithString("NextToken", next_token.c_str()); + + const auto groups_request_body = groups_request.View().WriteCompact(); + auto page = AwsSSO::parseGroupMemberships(identityStoreRequest( + "AWSIdentityStore.ListGroupMembershipsForMember", + String(groups_request_body.c_str(), groups_request_body.size()), provider)); + group_ids.insert(page.group_ids.begin(), page.group_ids.end()); + if (page.next_token.empty()) + return group_ids; + if (page.next_token == next_token) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store returned a repeated pagination token"); + next_token = std::move(page.next_token); + } + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store group membership exceeds {} pages", max_group_pages); +} + +bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const +{ + /// Auto-discovery stops on exceptions, so AWS and transport failures must deny only this processor. + try + { + if (!isSafeHeader(credentials.getToken())) + return false; + + Poco::URI portal(getPortalEndpoint() + "/federation/credentials"); + portal.addQueryParameter("account_id", account_id); + portal.addQueryParameter("role_name", role_name); + const auto role_response = request( + Poco::Net::HTTPRequest::HTTP_GET, portal.toString(), + {{"x-amz-sso_bearer_token", credentials.getToken()}, {"Accept", "application/json"}}); + if (role_response.status != 200) + return false; + + picojson::value json; + if (!parseWholeJSON(json, role_response.body).empty()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS SSO JSON response"); + const auto & role_credentials = requiredField(json, "roleCredentials"); + const auto access_key = requiredString(role_credentials, "accessKeyId"); + const auto secret_key = requiredString(role_credentials, "secretAccessKey"); + const auto session_token = requiredString(role_credentials, "sessionToken"); + const auto & expiration = requiredField(role_credentials, "expiration"); + if (!expiration.is()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS SSO credential expiration"); + const auto expiration_ms = expiration.get(); + const auto now = std::chrono::system_clock::now(); + const auto now_ms = std::chrono::duration_cast(now.time_since_epoch()).count(); + if (expiration_ms <= now_ms) + return false; + + /// Initialize the shared AWS SDK before signing. + S3::ClientFactory::instance(); + auto provider = std::make_shared(access_key, secret_key, session_token); + Aws::Client::AWSAuthV4Signer signer(provider, "sts", region); + Aws::Http::Standard::StandardHttpRequest sts_request(Aws::Http::URI(getSTSEndpoint()), Aws::Http::HttpMethod::HTTP_GET); + if (!signer.SignRequest(sts_request)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot sign AWS STS identity request"); + Headers headers; + for (const auto & [name, value] : sts_request.GetHeaders()) + headers.emplace_back(name, value); + const auto identity_response = request(Poco::Net::HTTPRequest::HTTP_GET, getSTSEndpoint(), headers); + if (identity_response.status != 200) + return false; + + auto document = Aws::Utils::Xml::XmlDocument::CreateFromXmlString(identity_response.body); + if (!document.WasParseSuccessful()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS STS XML response"); + auto root = document.GetRootElement(); + if (root.IsNull() || root.GetName() != "GetCallerIdentityResponse") + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS STS identity response"); + auto result = root.FirstChild("GetCallerIdentityResult"); + if (result.IsNull() || !result.NextNode("GetCallerIdentityResult").IsNull()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS STS identity response"); + const auto arn = xmlField(result, "Arn"); + const auto user_id = xmlField(result, "UserId"); + if (xmlField(result, "Account") != account_id) + return false; + const auto prefix = "arn:" + partition + ":sts::" + account_id + ":assumed-role/AWSReservedSSO_" + role_name + "_"; + if (!arn.starts_with(prefix)) + return false; + const auto suffix = arn.substr(prefix.size()); + const auto slash = suffix.find('/'); + if (slash != 16 || suffix.size() <= slash + 1 || suffix.find('/', slash + 1) != String::npos + || !std::all_of(suffix.begin(), suffix.begin() + 16, [](char c) + { + return (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || (c >= '0' && c <= '9'); + })) + return false; + const auto session_name = suffix.substr(slash + 1); + const auto colon = user_id.find(':'); + if (!user_id.starts_with("AROA") || colon == String::npos || user_id.substr(colon + 1) != session_name) + return false; + + /// AWS reports role-credential expiry, not access-token expiry; cap the session by the cache TTL. + const auto valid_for_ms = std::min(expiration_ms - now_ms, token_cache_lifetime * 1000); + const auto expires_at = now + std::chrono::milliseconds(valid_for_ms); + if (expires_at <= std::chrono::system_clock::now()) + return false; + credentials.setUserName(arn); + credentials.setGroups(identity_store_id.empty() ? std::set{} : getGroupIds(session_name, provider)); + credentials.setExpiresAt(expires_at); + return true; + } + catch (const std::exception & ex) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to validate AWS SSO access token: {}", processor_name, ex.what()); + return false; + } +} + +} + +#endif diff --git a/src/Access/AwsSSOTokenProcessor.h b/src/Access/AwsSSOTokenProcessor.h new file mode 100644 index 000000000000..5f0cff3941a3 --- /dev/null +++ b/src/Access/AwsSSOTokenProcessor.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL + +namespace Aws::Auth +{ +class AWSCredentialsProvider; +} + +namespace DB +{ + +namespace AwsSSO +{ +struct GroupMembershipsPage +{ + std::set group_ids; + String next_token; +}; + +String parseUserId(const String & response); +GroupMembershipsPage parseGroupMemberships(const String & response); +} + +class AwsSSOTokenProcessor : public ITokenProcessor +{ +public: + AwsSSOTokenProcessor(const String & name, UInt64 cache_lifetime, const String & region_, + const String & account_id_, const String & role_name_, const String & identity_store_id_, + const ConnectionTimeouts & timeouts_); + + bool resolveAndValidate(TokenCredentials & credentials) const override; + String getPortalEndpoint() const; + String getSTSEndpoint() const; + String getIdentityStoreEndpoint() const; + +private: + using Headers = std::vector>; + struct Response + { + int status; + String body; + }; + + const String region; + const String account_id; + const String role_name; + const String identity_store_id; + const String partition; + const String domain; + const ConnectionTimeouts timeouts; + + Response request(const String & method, const String & url, const Headers & headers, const String & body = {}) const; + String identityStoreRequest( + const String & target, const String & body, const std::shared_ptr & provider) const; + std::set getGroupIds( + const String & user_name, const std::shared_ptr & provider) const; +}; + +} + +#endif diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp index 0b0273e8433c..842f52587416 100644 --- a/src/Access/TokenProcessorsParse.cpp +++ b/src/Access/TokenProcessorsParse.cpp @@ -1,4 +1,5 @@ #include "TokenProcessors.h" +#include #include #include @@ -87,6 +88,40 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( { return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, expected_audience, timeouts); } + else if (provider_type == "aws_sso") + { +#if USE_AWS_S3 && USE_SSL + for (const auto * key : {"region", "account_id", "role_name"}) + { + if (!config.hasProperty(prefix + "." + key)) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, + "Token processor '{}': '{}' must be specified for 'aws_sso' processor", processor_name, key); + } + for (const auto * key : {"username_claim", "groups_claim", "expected_issuer", "expected_audience", "expected_typ", + "claims", "allow_no_expiration", "jwks_uri", "configuration_endpoint", "userinfo_endpoint", + "token_introspection_endpoint"}) + { + if (config.hasProperty(prefix + "." + key)) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, + "Token processor '{}': '{}' is not supported by aws_sso", processor_name, key); + } + const auto identity_store_id = config.getString(prefix + ".identity_store_id", ""); + if (config.hasProperty(prefix + ".identity_store_id") && identity_store_id.empty()) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, + "Token processor '{}': 'identity_store_id' must not be empty", processor_name); + auto processor = std::make_unique( + processor_name, config.getUInt64(prefix + ".token_cache_lifetime", 60), + config.getString(prefix + ".region"), config.getString(prefix + ".account_id"), + config.getString(prefix + ".role_name"), identity_store_id, timeouts); + require_allowed_url(processor->getPortalEndpoint(), "region"); + require_allowed_url(processor->getSTSEndpoint(), "region"); + if (!identity_store_id.empty()) + require_allowed_url(processor->getIdentityStoreEndpoint(), "region"); + return processor; +#else + throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "AWS SSO requires AWS SDK and SSL support"); +#endif + } else if (provider_type == "openid") { bool externally_configured = config.hasProperty(prefix + ".configuration_endpoint"); diff --git a/src/Access/tests/gtest_aws_sso_token_processor.cpp b/src/Access/tests/gtest_aws_sso_token_processor.cpp new file mode 100644 index 000000000000..8c5048e62da3 --- /dev/null +++ b/src/Access/tests/gtest_aws_sso_token_processor.cpp @@ -0,0 +1,49 @@ +#include + +#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL + +#include + +#include + +namespace DB +{ +namespace +{ + +TEST(AwsSSOTokenProcessor, ParsesIdentityStoreUserId) +{ + EXPECT_EQ(AwsSSO::parseUserId(R"({"IdentityStoreId":"d-1234567890","UserId":"1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"})"), + "1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + EXPECT_THROW(AwsSSO::parseUserId(R"({"IdentityStoreId":"d-1234567890"})"), Exception); + EXPECT_THROW(AwsSSO::parseUserId(R"({"UserId":"invalid\nuser"})"), Exception); +} + +TEST(AwsSSOTokenProcessor, ParsesIdentityStoreGroupMemberships) +{ + const auto page = AwsSSO::parseGroupMemberships(R"({ + "GroupMemberships":[ + {"GroupId":"1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, + {"GroupId":"1234567890-11111111-2222-3333-4444-555555555555"}, + {"GroupId":"1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} + ], + "NextToken":"next-page" + })"); + + EXPECT_EQ(page.group_ids, (std::set{ + "1234567890-11111111-2222-3333-4444-555555555555", + "1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"})); + EXPECT_EQ(page.next_token, "next-page"); +} + +TEST(AwsSSOTokenProcessor, RejectsInvalidIdentityStoreGroupMemberships) +{ + EXPECT_THROW(AwsSSO::parseGroupMemberships(R"({"GroupMemberships":{}})"), Exception); + EXPECT_THROW(AwsSSO::parseGroupMemberships(R"({"GroupMemberships":[{}]})"), Exception); + EXPECT_THROW(AwsSSO::parseGroupMemberships(R"({"GroupMemberships":[],"NextToken":42})"), Exception); +} + +} +} + +#endif