From da7f6af06912ce834a109f6f43f43f3d5957c16e Mon Sep 17 00:00:00 2001 From: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:00:53 +0200 Subject: [PATCH 1/2] out_stackdriver: support external_account (Workload Identity Federation) Add Workload Identity Federation (external_account) credential support to the Stackdriver output plugin so workloads running outside GCP can write to Cloud Logging using short-lived federated tokens instead of long-lived service account keys. The new auth flow reads a subject token from credential_source (file or URL, text or JSON format), exchanges it at sts.googleapis.com using the RFC 8693 token-exchange grant, and optionally trades the federated token for a service account token via IAM Credentials. Both direct WIF principal bindings (principal://) and service-account impersonation (with delegation chains) are supported. Behavior is aligned with the Google reference SDK: - quota_project_id forwarded as x-goog-user-project on outbound calls - universe_domain substituted into default STS and Logging endpoints - audience and subject_token_type validated as required - workforce_pool_user_project rejected unless the audience matches the workforce-pool pattern - STS responses with expires_in == 0 rejected - x-goog-api-client telemetry header set on STS calls - service_account_impersonation.delegates parsed and forwarded - form bodies use a strict RFC 3986 encoder; impersonation and STS options bodies are built via yyjson_mut so values with quotes, backslashes or form-separator characters cannot corrupt requests File and URL credential_source variants are supported, with both text and JSON format types. The executable, AWS (environment_id) and X.509 certificate variants are not yet supported and are rejected at parse time with a clear error message; they can be added in follow-up changes. Migrating the credentials parser to yyjson ========================================== external_account credential files have three levels of nested objects plus arrays (credential_source.format, credential_source.headers, service_account_impersonation.delegates). The previous JSMN-based parser handled this with parent-index walks across multiple passes, which is brittle and verbose for nested data. The existing service_account parser was written in 2018 when JSMN was the only option available in the tree. yyjson was vendored into fluent-bit in #10766 (Sep 2025) and made the default JSON backend in #11562 (Mar 2026), with explicit guidance to migrate components incrementally. Adding a deeply nested credential type is a natural moment to do that here. Both the service_account and external_account paths now share one yyjson-based parser. Verified end-to-end against a Google Cloud project: subject token fetched from a Kubernetes service account token, exchanged at sts.googleapis.com, used to write log entries to Cloud Logging via a principal:// binding. The URL credential_source path was verified against a local HTTP token server in both text and JSON formats. Signed-off-by: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com> --- plugins/out_stackdriver/CMakeLists.txt | 1 + plugins/out_stackdriver/stackdriver.c | 21 + plugins/out_stackdriver/stackdriver.h | 65 + plugins/out_stackdriver/stackdriver_conf.c | 485 +++++-- .../stackdriver_external_account.c | 1177 +++++++++++++++++ .../stackdriver_external_account.h | 47 + 6 files changed, 1696 insertions(+), 100 deletions(-) create mode 100644 plugins/out_stackdriver/stackdriver_external_account.c create mode 100644 plugins/out_stackdriver/stackdriver_external_account.h diff --git a/plugins/out_stackdriver/CMakeLists.txt b/plugins/out_stackdriver/CMakeLists.txt index 2d7fa71bb70..bd841fe2b27 100644 --- a/plugins/out_stackdriver/CMakeLists.txt +++ b/plugins/out_stackdriver/CMakeLists.txt @@ -8,6 +8,7 @@ set(src stackdriver_timestamp.c stackdriver_helper.c stackdriver_resource_types.c + stackdriver_external_account.c ) FLB_PLUGIN(out_stackdriver "${src}" "") diff --git a/plugins/out_stackdriver/stackdriver.c b/plugins/out_stackdriver/stackdriver.c index 38fefd7a8fd..654a4cebe1d 100644 --- a/plugins/out_stackdriver/stackdriver.c +++ b/plugins/out_stackdriver/stackdriver.c @@ -36,6 +36,7 @@ #include +#include "stackdriver_external_account.h" #include "gce_metadata.h" #include "stackdriver.h" #include "stackdriver_conf.h" @@ -355,6 +356,11 @@ static int get_oauth2_token(struct flb_stackdriver *ctx) return gce_metadata_read_token(ctx); } + /* Workload Identity Federation (external_account) */ + if (stackdriver_external_account_is_configured(ctx)) { + return stackdriver_external_account_read_token(ctx); + } + /* JWT encode for oauth2 */ issued = time(NULL); expires = issued + FLB_STD_TOKEN_REFRESH; @@ -3044,6 +3050,21 @@ static void cb_stackdriver_flush(struct flb_event_chunk *event_chunk, flb_http_add_header(c, "Content-Type", 12, "application/json", 16); flb_http_add_header(c, "Authorization", 13, token, flb_sds_len(token)); + + /* + * quota_project_id (when set in the credentials file) controls which + * project gets billed and quota-counted for outbound API calls. It is + * forwarded as x-goog-user-project so cross-project setups (federated + * identity in project A, logs to project B, billing to project C) + * charge the right tenant. + */ + if (ctx->creds && ctx->creds->quota_project_id && + flb_sds_len(ctx->creds->quota_project_id) > 0) { + flb_http_add_header(c, "x-goog-user-project", 19, + ctx->creds->quota_project_id, + flb_sds_len(ctx->creds->quota_project_id)); + } + /* Content Encoding: gzip */ if (compressed == FLB_TRUE) { flb_http_set_content_encoding_gzip(c); diff --git a/plugins/out_stackdriver/stackdriver.h b/plugins/out_stackdriver/stackdriver.h index f54faa9d339..d22c9f68092 100644 --- a/plugins/out_stackdriver/stackdriver.h +++ b/plugins/out_stackdriver/stackdriver.h @@ -38,6 +38,27 @@ /* Stackdriver authorization URL */ #define FLB_STD_AUTH_URL "https://oauth2.googleapis.com/token" +/* + * Workload Identity Federation (external_account) — see + * https://cloud.google.com/iam/docs/workload-identity-federation + */ +#define FLB_STD_CREDENTIAL_TYPE_SERVICE_ACCOUNT "service_account" +#define FLB_STD_CREDENTIAL_TYPE_EXTERNAL_ACCOUNT "external_account" + +#define FLB_STD_DEFAULT_STS_TOKEN_URL "https://sts.googleapis.com/v1/token" + +/* Scope used for the federated access token when impersonation is enabled */ +#define FLB_STD_IAM_SCOPE "https://www.googleapis.com/auth/cloud-platform" + +/* OAuth 2.0 token exchange grant_type and requested_token_type (RFC 8693) */ +#define FLB_STD_TOKEN_EXCHANGE_GRANT_TYPE \ + "urn:ietf:params:oauth:grant-type:token-exchange" +#define FLB_STD_TOKEN_TYPE_ACCESS_TOKEN \ + "urn:ietf:params:oauth:token-type:access_token" + +/* Default lifetime requested when impersonating a service account */ +#define FLB_STD_DEFAULT_IMPERSONATION_LIFETIME_SECONDS 3600 + /* Stackdriver Logging 'write' end-point */ #define FLB_STD_WRITE_URI "/v2/entries:write" #define FLB_STD_WRITE_URI_SIZE 17 @@ -105,6 +126,39 @@ struct flb_stackdriver_oauth_credentials { flb_sds_t client_id; flb_sds_t auth_uri; flb_sds_t token_uri; + + /* + * Workload Identity Federation (type == "external_account"). + * Supports file-based and URL-based credential_source variants; + * executable, aws and certificate variants are rejected at parse time. + */ + flb_sds_t client_secret; + flb_sds_t audience; + flb_sds_t subject_token_type; + flb_sds_t token_url; + flb_sds_t service_account_impersonation_url; + int sa_impersonation_lifetime_seconds; + /* + * Optional impersonation delegation chain. Each delegate must have + * iam.serviceAccountTokenCreator on the next; the final email is the + * service account whose access token gets returned. + */ + flb_sds_t *sa_impersonation_delegates; + int sa_impersonation_delegates_count; + flb_sds_t cred_source_file; + flb_sds_t cred_source_url; + /* + * Optional HTTP headers sent with the credential_source.url GET. The + * three arrays are parallel: keys[i] / vals[i] form one header. + */ + flb_sds_t *cred_source_headers_keys; + flb_sds_t *cred_source_headers_vals; + int cred_source_headers_count; + flb_sds_t cred_source_format_type; /* "text" or "json" */ + flb_sds_t cred_source_format_subject_field; + flb_sds_t workforce_pool_user_project; + flb_sds_t quota_project_id; + flb_sds_t universe_domain; }; struct flb_stackdriver_env { @@ -215,6 +269,17 @@ struct flb_stackdriver { /* upstream context for metadata end-point */ struct flb_upstream *metadata_u; + /* + * Upstream contexts for Workload Identity Federation. They are created + * lazily (under token_mutex) by stackdriver_external_account_read_token(), so + * that the plugin still loads when the credentials file is absent or + * uses a non-WIF type. + */ + struct flb_upstream *wif_sts_u; + struct flb_upstream *wif_iam_u; + /* Upstream for credential_source.url subject-token fetch. */ + struct flb_upstream *wif_subject_url_u; + /* the key to extract unstructured text payload from */ flb_sds_t text_payload_key; diff --git a/plugins/out_stackdriver/stackdriver_conf.c b/plugins/out_stackdriver/stackdriver_conf.c index 7c36b2e02b9..a5c6f5c93b4 100644 --- a/plugins/out_stackdriver/stackdriver_conf.c +++ b/plugins/out_stackdriver/stackdriver_conf.c @@ -22,44 +22,54 @@ #include #include #include -#include #include #include +#include #include #include +#include "stackdriver_external_account.h" #include "gce_metadata.h" #include "stackdriver.h" #include "stackdriver_conf.h" #include "stackdriver_resource_types.h" -static inline int key_cmp(const char *str, int len, const char *cmp) { - - if (strlen(cmp) != len) { - return -1; - } - - return strncasecmp(str, cmp, len); -} - -static int read_credentials_file(const char *cred_file, struct flb_stackdriver *ctx) +/* + * Read and parse a Google credentials JSON file. Handles both + * service_account (SA-key) and external_account (Workload Identity + * Federation) shapes from the same parser. Returns 0 on success, -1 on + * any failure (file not found, malformed JSON, unsupported variant). + */ +static int read_credentials_file(const char *cred_file, + struct flb_stackdriver *ctx) { - int i; int ret; - int key_len; - int val_len; - int tok_size = 32; - char *buf; - char *key; - char *val; + int rc = -1; + size_t pk_len; + size_t header_count; + size_t header_written; + size_t arr_size; + size_t idx; + size_t max; + size_t delegates_written; + const char *pk_str; + char *buf = NULL; flb_sds_t tmp; struct stat st; - jsmn_parser parser; - jsmntok_t *t; - jsmntok_t *tokens; + yyjson_doc *doc = NULL; + yyjson_val *root; + yyjson_val *v; + yyjson_val *cs; + yyjson_val *fmt; + yyjson_val *headers; + yyjson_val *sai; + yyjson_val *delegates; + yyjson_val *hk; + yyjson_val *hv; + yyjson_val *item; + yyjson_obj_iter hdr_iter; - /* Validate credentials path */ ret = stat(cred_file, &st); if (ret == -1) { flb_errno(); @@ -67,14 +77,12 @@ static int read_credentials_file(const char *cred_file, struct flb_stackdriver * cred_file); return -1; } - if (!S_ISREG(st.st_mode) && !S_ISLNK(st.st_mode)) { - flb_plg_error(ctx->ins, "credentials file " - "is not a valid file: %s", cred_file); + flb_plg_error(ctx->ins, "credentials file is not a valid file: %s", + cred_file); return -1; } - /* Read file content */ buf = mk_file_to_buffer(cred_file); if (!buf) { flb_plg_error(ctx->ins, "error reading credentials file: %s", @@ -82,93 +90,206 @@ static int read_credentials_file(const char *cred_file, struct flb_stackdriver * return -1; } - /* Parse content */ - jsmn_init(&parser); - tokens = flb_calloc(1, sizeof(jsmntok_t) * tok_size); - if (!tokens) { - flb_errno(); - flb_free(buf); - return -1; - } - - ret = jsmn_parse(&parser, buf, st.st_size, tokens, tok_size); - if (ret <= 0) { + doc = yyjson_read(buf, st.st_size, 0); + if (!doc) { flb_plg_error(ctx->ins, "invalid JSON credentials file: %s", - cred_file); - flb_free(buf); - flb_free(tokens); - return -1; - } - - t = &tokens[0]; - if (t->type != JSMN_OBJECT) { - flb_plg_error(ctx->ins, "invalid JSON map on file: %s", - cred_file); - flb_free(buf); - flb_free(tokens); - return -1; + cred_file); + goto out; + } + root = yyjson_doc_get_root(doc); + if (!yyjson_is_obj(root)) { + flb_plg_error(ctx->ins, "invalid JSON map on file: %s", cred_file); + goto out; + } + +#define COPY_CREDS_STR(field, key) \ + do { \ + v = yyjson_obj_get(root, (key)); \ + if (yyjson_is_str(v)) { \ + ctx->creds->field = flb_sds_create(yyjson_get_str(v)); \ + } \ + } while (0) + + COPY_CREDS_STR(type, "type"); + COPY_CREDS_STR(private_key_id, "private_key_id"); + COPY_CREDS_STR(client_email, "client_email"); + COPY_CREDS_STR(client_id, "client_id"); + COPY_CREDS_STR(client_secret, "client_secret"); + COPY_CREDS_STR(auth_uri, "auth_uri"); + COPY_CREDS_STR(token_uri, "token_uri"); + COPY_CREDS_STR(audience, "audience"); + COPY_CREDS_STR(subject_token_type, "subject_token_type"); + COPY_CREDS_STR(token_url, "token_url"); + COPY_CREDS_STR(service_account_impersonation_url, + "service_account_impersonation_url"); + COPY_CREDS_STR(workforce_pool_user_project, + "workforce_pool_user_project"); + COPY_CREDS_STR(quota_project_id, "quota_project_id"); + COPY_CREDS_STR(universe_domain, "universe_domain"); + +#undef COPY_CREDS_STR + + /* project_id lives on ctx itself (not ctx->creds) for the SA-key flow */ + v = yyjson_obj_get(root, "project_id"); + if (yyjson_is_str(v)) { + ctx->project_id = flb_sds_create(yyjson_get_str(v)); + } + + /* private_key needs unescape (PEM newlines come through as "\n") */ + v = yyjson_obj_get(root, "private_key"); + if (yyjson_is_str(v)) { + pk_len = yyjson_get_len(v); + pk_str = yyjson_get_str(v); + tmp = flb_sds_create_len(pk_str, pk_len); + if (tmp) { + ctx->creds->private_key = flb_sds_create_size(pk_len); + flb_unescape_string(tmp, flb_sds_len(tmp), + &ctx->creds->private_key); + flb_sds_destroy(tmp); + } } - /* Parse JSON tokens */ - for (i = 1; i < ret; i++) { - t = &tokens[i]; - if (t->type != JSMN_STRING) { - continue; + /* credential_source — file or url, plus optional format and headers */ + cs = yyjson_obj_get(root, "credential_source"); + if (yyjson_is_obj(cs)) { + v = yyjson_obj_get(cs, "file"); + if (yyjson_is_str(v)) { + ctx->creds->cred_source_file = + flb_sds_create(yyjson_get_str(v)); } - - if (t->start == -1 || t->end == -1 || (t->start == 0 && t->end == 0)){ - break; + v = yyjson_obj_get(cs, "url"); + if (yyjson_is_str(v)) { + ctx->creds->cred_source_url = + flb_sds_create(yyjson_get_str(v)); } - /* Key */ - key = buf + t->start; - key_len = (t->end - t->start); - - /* Value */ - i++; - t = &tokens[i]; - val = buf + t->start; - val_len = (t->end - t->start); - - if (key_cmp(key, key_len, "type") == 0) { - ctx->creds->type = flb_sds_create_len(val, val_len); - } - else if (key_cmp(key, key_len, "project_id") == 0) { - ctx->project_id = flb_sds_create_len(val, val_len); - } - else if (key_cmp(key, key_len, "private_key_id") == 0) { - ctx->creds->private_key_id = flb_sds_create_len(val, val_len); + fmt = yyjson_obj_get(cs, "format"); + if (yyjson_is_obj(fmt)) { + v = yyjson_obj_get(fmt, "type"); + if (yyjson_is_str(v)) { + ctx->creds->cred_source_format_type = + flb_sds_create(yyjson_get_str(v)); + } + v = yyjson_obj_get(fmt, "subject_token_field_name"); + if (yyjson_is_str(v)) { + ctx->creds->cred_source_format_subject_field = + flb_sds_create(yyjson_get_str(v)); + } } - else if (key_cmp(key, key_len, "private_key") == 0) { - tmp = flb_sds_create_len(val, val_len); - if (tmp) { - /* Unescape private key */ - ctx->creds->private_key = flb_sds_create_size(val_len); - flb_unescape_string(tmp, flb_sds_len(tmp), - &ctx->creds->private_key); - flb_sds_destroy(tmp); + + /* credential_source.headers — flat string -> string map */ + headers = yyjson_obj_get(cs, "headers"); + if (yyjson_is_obj(headers)) { + header_count = yyjson_obj_size(headers); + if (header_count > 0) { + header_written = 0; + ctx->creds->cred_source_headers_keys = + flb_calloc(header_count, sizeof(flb_sds_t)); + ctx->creds->cred_source_headers_vals = + flb_calloc(header_count, sizeof(flb_sds_t)); + if (!ctx->creds->cred_source_headers_keys || + !ctx->creds->cred_source_headers_vals) { + flb_errno(); + /* + * If only one calloc succeeded, free it now so the + * orphan does not leak — the conf_destroy iterator + * stops at cred_source_headers_count (still 0) and + * never reaches the surviving allocation. + */ + flb_free(ctx->creds->cred_source_headers_keys); + flb_free(ctx->creds->cred_source_headers_vals); + ctx->creds->cred_source_headers_keys = NULL; + ctx->creds->cred_source_headers_vals = NULL; + goto out; + } + yyjson_obj_iter_init(headers, &hdr_iter); + while ((hk = yyjson_obj_iter_next(&hdr_iter)) != NULL && + header_written < header_count) { + hv = yyjson_obj_iter_get_val(hk); + if (!yyjson_is_str(hv)) { + continue; + } + ctx->creds->cred_source_headers_keys[header_written] = + flb_sds_create(yyjson_get_str(hk)); + ctx->creds->cred_source_headers_vals[header_written] = + flb_sds_create(yyjson_get_str(hv)); + header_written++; + } + ctx->creds->cred_source_headers_count = (int) header_written; } } - else if (key_cmp(key, key_len, "client_email") == 0) { - ctx->creds->client_email = flb_sds_create_len(val, val_len); + + /* Reject unsupported credential_source variants up front */ + if (ctx->creds->type && + strcmp(ctx->creds->type, + FLB_STD_CREDENTIAL_TYPE_EXTERNAL_ACCOUNT) == 0) { + if (yyjson_is_obj(yyjson_obj_get(cs, "executable"))) { + flb_plg_error(ctx->ins, "external_account: executable-" + "sourced credential_source is not supported"); + goto out; + } + if (yyjson_is_str(yyjson_obj_get(cs, "environment_id"))) { + flb_plg_error(ctx->ins, "external_account: AWS-sourced " + "credential_source is not supported"); + goto out; + } + if (yyjson_is_obj(yyjson_obj_get(cs, "certificate"))) { + flb_plg_error(ctx->ins, "external_account: certificate-" + "based credential_source is not supported"); + goto out; + } } - else if (key_cmp(key, key_len, "client_id") == 0) { - ctx->creds->client_id = flb_sds_create_len(val, val_len); + } + + /* service_account_impersonation — lifetime + delegation chain */ + sai = yyjson_obj_get(root, "service_account_impersonation"); + if (yyjson_is_obj(sai)) { + v = yyjson_obj_get(sai, "token_lifetime_seconds"); + if (yyjson_is_int(v)) { + ctx->creds->sa_impersonation_lifetime_seconds = + (int) yyjson_get_int(v); } - else if (key_cmp(key, key_len, "auth_uri") == 0) { - ctx->creds->auth_uri = flb_sds_create_len(val, val_len); + else if (v != NULL && !yyjson_is_null(v)) { + flb_plg_warn(ctx->ins, "ignoring invalid token_lifetime_seconds " + "in %s", cred_file); } - else if (key_cmp(key, key_len, "token_uri") == 0) { - ctx->creds->token_uri = flb_sds_create_len(val, val_len); + + delegates = yyjson_obj_get(sai, "delegates"); + if (yyjson_is_arr(delegates)) { + arr_size = yyjson_arr_size(delegates); + if (arr_size > 0) { + delegates_written = 0; + ctx->creds->sa_impersonation_delegates = + flb_calloc(arr_size, sizeof(flb_sds_t)); + if (!ctx->creds->sa_impersonation_delegates) { + flb_errno(); + goto out; + } + yyjson_arr_foreach(delegates, idx, max, item) { + if (!yyjson_is_str(item)) { + continue; + } + ctx->creds->sa_impersonation_delegates[delegates_written] = + flb_sds_create(yyjson_get_str(item)); + delegates_written++; + } + ctx->creds->sa_impersonation_delegates_count = + (int) delegates_written; + } } } - flb_free(buf); - flb_free(tokens); + rc = 0; - return 0; +out: + if (doc) { + yyjson_doc_free(doc); + } + if (buf) { + flb_free(buf); + } + return rc; } - /* * parse_key_value_list(): * - Parses an origin list of comma seperated string specifying key=value. @@ -445,9 +566,83 @@ struct flb_stackdriver *flb_stackdriver_conf_create(struct flb_output_instance * ctx->client_email = ctx->creds->client_email; } if (!ctx->private_key) { - flb_plg_warn(ctx->ins, "private_key is not defined, fetching " - "it from metadata server"); - ctx->metadata_server_auth = true; + if (stackdriver_external_account_is_configured(ctx)) { + flb_plg_info(ctx->ins, "using Workload Identity Federation " + "(external_account credentials)"); + + /* Required-field validation for external_account credentials. */ + if (!ctx->creds->audience || + flb_sds_len(ctx->creds->audience) == 0) { + flb_plg_error(ctx->ins, "external_account: 'audience' is " + "required"); + flb_stackdriver_conf_destroy(ctx); + return NULL; + } + if (!ctx->creds->subject_token_type || + flb_sds_len(ctx->creds->subject_token_type) == 0) { + flb_plg_error(ctx->ins, "external_account: " + "'subject_token_type' is required"); + flb_stackdriver_conf_destroy(ctx); + return NULL; + } + /* + * workforce_pool_user_project only makes sense for workforce + * pool audiences. Audiences pointing at workload identity pools + * carry their own project context via the pool path itself, so + * setting workforce_pool_user_project there is a configuration + * mistake. + */ + if (ctx->creds->workforce_pool_user_project && + flb_sds_len(ctx->creds->workforce_pool_user_project) > 0 && + strstr(ctx->creds->audience, + "//iam.googleapis.com/locations/") == NULL) { + flb_plg_error(ctx->ins, "external_account: " + "workforce_pool_user_project is only valid " + "for workforce pool audiences"); + flb_stackdriver_conf_destroy(ctx); + return NULL; + } + + /* + * If the credential_source uses JSON format, the field name + * to extract from must be set. Validating here surfaces the + * misconfiguration at init time rather than on the first + * flush attempt. + */ + if (ctx->creds->cred_source_format_type && + strcasecmp(ctx->creds->cred_source_format_type, + "json") == 0 && + (!ctx->creds->cred_source_format_subject_field || + flb_sds_len(ctx->creds->cred_source_format_subject_field) + == 0)) { + flb_plg_error(ctx->ins, "external_account: " + "credential_source.format.subject_token_field_name " + "is required when format.type is 'json'"); + flb_stackdriver_conf_destroy(ctx); + return NULL; + } + + /* + * external_account credentials carry no "project_id" field + * (unlike a service-account JSON), so the SA-key-derived + * fallback that fills ctx->project_id at parse time never + * fires. Default it to export_to_project_id when set, so the + * init-time validation in cb_stackdriver_init() succeeds and + * the monitored_resource.project_id label gets the same + * destination project the user already configured. + */ + if (!ctx->project_id && ctx->export_to_project_id) { + ctx->project_id = flb_sds_create(ctx->export_to_project_id); + flb_plg_info(ctx->ins, "external_account: defaulting " + "project_id to export_to_project_id (%s)", + ctx->project_id); + } + } + else { + flb_plg_warn(ctx->ins, "private_key is not defined, fetching " + "it from metadata server"); + ctx->metadata_server_auth = true; + } } if (ctx->http_request_key) { @@ -488,7 +683,21 @@ struct flb_stackdriver *flb_stackdriver_conf_create(struct flb_output_instance * "%s%s", cloud_logging_base_url_str, FLB_STD_WRITE_URI); flb_sds_destroy(cloud_logging_base_url_str); - } else { + } + else if (ctx->creds && ctx->creds->universe_domain && + flb_sds_len(ctx->creds->universe_domain) > 0 && + strcmp(ctx->creds->universe_domain, "googleapis.com") != 0) { + /* + * Non-default Cloud universe and no explicit override: derive the + * Logging API endpoint from universe_domain so sovereign/GDU + * tenants reach their own logging. host. + */ + ctx->cloud_logging_write_url = flb_sds_create_size(96); + flb_sds_snprintf(&ctx->cloud_logging_write_url, 96, + "https://logging.%s%s", + ctx->creds->universe_domain, FLB_STD_WRITE_URI); + } + else { ctx->cloud_logging_write_url = flb_sds_create(FLB_STD_WRITE_URL); } @@ -613,6 +822,9 @@ struct flb_stackdriver *flb_stackdriver_conf_create(struct flb_output_instance * int flb_stackdriver_conf_destroy(struct flb_stackdriver *ctx) { + int d; + int h; + if (!ctx) { return -1; } @@ -633,12 +845,73 @@ int flb_stackdriver_conf_destroy(struct flb_stackdriver *ctx) if (ctx->creds->client_id) { flb_sds_destroy(ctx->creds->client_id); } + if (ctx->creds->client_secret) { + flb_sds_destroy(ctx->creds->client_secret); + } if (ctx->creds->auth_uri) { flb_sds_destroy(ctx->creds->auth_uri); } if (ctx->creds->token_uri) { flb_sds_destroy(ctx->creds->token_uri); } + if (ctx->creds->audience) { + flb_sds_destroy(ctx->creds->audience); + } + if (ctx->creds->subject_token_type) { + flb_sds_destroy(ctx->creds->subject_token_type); + } + if (ctx->creds->token_url) { + flb_sds_destroy(ctx->creds->token_url); + } + if (ctx->creds->service_account_impersonation_url) { + flb_sds_destroy(ctx->creds->service_account_impersonation_url); + } + if (ctx->creds->sa_impersonation_delegates) { + for (d = 0; + d < ctx->creds->sa_impersonation_delegates_count; + d++) { + if (ctx->creds->sa_impersonation_delegates[d]) { + flb_sds_destroy( + ctx->creds->sa_impersonation_delegates[d]); + } + } + flb_free(ctx->creds->sa_impersonation_delegates); + } + if (ctx->creds->cred_source_file) { + flb_sds_destroy(ctx->creds->cred_source_file); + } + if (ctx->creds->cred_source_url) { + flb_sds_destroy(ctx->creds->cred_source_url); + } + if (ctx->creds->cred_source_headers_keys) { + for (h = 0; h < ctx->creds->cred_source_headers_count; h++) { + if (ctx->creds->cred_source_headers_keys[h]) { + flb_sds_destroy( + ctx->creds->cred_source_headers_keys[h]); + } + if (ctx->creds->cred_source_headers_vals[h]) { + flb_sds_destroy( + ctx->creds->cred_source_headers_vals[h]); + } + } + flb_free(ctx->creds->cred_source_headers_keys); + flb_free(ctx->creds->cred_source_headers_vals); + } + if (ctx->creds->cred_source_format_type) { + flb_sds_destroy(ctx->creds->cred_source_format_type); + } + if (ctx->creds->cred_source_format_subject_field) { + flb_sds_destroy(ctx->creds->cred_source_format_subject_field); + } + if (ctx->creds->workforce_pool_user_project) { + flb_sds_destroy(ctx->creds->workforce_pool_user_project); + } + if (ctx->creds->quota_project_id) { + flb_sds_destroy(ctx->creds->quota_project_id); + } + if (ctx->creds->universe_domain) { + flb_sds_destroy(ctx->creds->universe_domain); + } flb_free(ctx->creds); } @@ -680,6 +953,18 @@ int flb_stackdriver_conf_destroy(struct flb_stackdriver *ctx) flb_upstream_destroy(ctx->metadata_u); } + if (ctx->wif_sts_u) { + flb_upstream_destroy(ctx->wif_sts_u); + } + + if (ctx->wif_iam_u) { + flb_upstream_destroy(ctx->wif_iam_u); + } + + if (ctx->wif_subject_url_u) { + flb_upstream_destroy(ctx->wif_subject_url_u); + } + if (ctx->u) { flb_upstream_destroy(ctx->u); } diff --git a/plugins/out_stackdriver/stackdriver_external_account.c b/plugins/out_stackdriver/stackdriver_external_account.c new file mode 100644 index 00000000000..c31c3581ddf --- /dev/null +++ b/plugins/out_stackdriver/stackdriver_external_account.c @@ -0,0 +1,1177 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* Fluent Bit + * ========== + * Copyright (C) 2015-2026 The Fluent Bit Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Workload Identity Federation support for the Stackdriver output plugin. + * + * Flow: + * 1. Read the subject token from credential_source. Two variants are + * supported: a local file (credential_source.file) or an HTTPS GET + * against credential_source.url with optional headers. Both honor + * credential_source.format (text or json). + * 2. Exchange it at the STS token endpoint for a federated access token. + * 3. If service_account_impersonation_url is set, call the IAM Credentials + * generateAccessToken endpoint (optionally through a delegation chain) + * to obtain the final service-account access token. + * 4. Store the resulting access token in ctx->o so that the existing + * get_google_token() machinery uses it transparently. + * + * Security note: token_url, service_account_impersonation_url and + * credential_source.url are trusted as supplied in the credentials file. + * Operators handling untrusted credential files should isolate fluent-bit + * at the network layer. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "stackdriver.h" +#include "stackdriver_external_account.h" + +/* Maximum size of any HTTP response body we care about (STS, impersonation) */ +#define WIF_HTTP_BUFFER_SIZE (64 * 1024) + +/* Maximum size we will accept for a subject token file */ +#define WIF_SUBJECT_TOKEN_MAX_SIZE (1 * 1024 * 1024) + +/* Forward declaration: definition appears below read_subject_token_from_url. */ +static struct flb_upstream *wif_get_upstream(struct flb_stackdriver *ctx, + struct flb_upstream **slot, + const char *url); + +int stackdriver_external_account_is_configured(struct flb_stackdriver *ctx) +{ + if (!ctx || !ctx->creds || !ctx->creds->type) { + return FLB_FALSE; + } + + if (strcmp(ctx->creds->type, + FLB_STD_CREDENTIAL_TYPE_EXTERNAL_ACCOUNT) == 0) { + return FLB_TRUE; + } + + return FLB_FALSE; +} + +/* Strip ASCII whitespace from both ends of an sds buffer (in-place) */ +static void wif_sds_trim_ws(flb_sds_t s) +{ + int i; + int len; + int start = 0; + int end; + + if (!s) { + return; + } + + len = flb_sds_len(s); + end = len; + + while (start < len && + (s[start] == ' ' || s[start] == '\t' || + s[start] == '\r' || s[start] == '\n')) { + start++; + } + + while (end > start && + (s[end - 1] == ' ' || s[end - 1] == '\t' || + s[end - 1] == '\r' || s[end - 1] == '\n')) { + end--; + } + + if (start == 0 && end == len) { + return; + } + + if (start > 0) { + for (i = 0; i < (end - start); i++) { + s[i] = s[start + i]; + } + } + + s[end - start] = '\0'; + flb_sds_len_set(s, end - start); +} + +/* + * Apply the credential_source.format rules to a freshly-fetched subject + * token buffer (whether it came from a file or an HTTP GET). Takes + * ownership of `raw` and returns a new flb_sds_t the caller must free. + * + * format == nil or "text" -> return raw, whitespace-trimmed + * format == "json" -> parse as JSON, extract subject_token_field_name + */ +static flb_sds_t extract_subject_token(struct flb_stackdriver *ctx, + const char *source_label, + flb_sds_t raw) +{ + flb_sds_t token = NULL; + yyjson_doc *doc = NULL; + yyjson_val *root; + yyjson_val *v; + const char *format_type; + const char *field_name; + + if (!raw) { + return NULL; + } + + wif_sds_trim_ws(raw); + + format_type = ctx->creds->cred_source_format_type; + if (!format_type || + flb_sds_len(ctx->creds->cred_source_format_type) == 0 || + strcasecmp(format_type, "text") == 0) { + return raw; + } + + if (strcasecmp(format_type, "json") != 0) { + flb_plg_error(ctx->ins, "external_account: unsupported " + "credential_source.format.type '%s' (expected " + "'text' or 'json')", format_type); + flb_sds_destroy(raw); + return NULL; + } + + field_name = ctx->creds->cred_source_format_subject_field; + if (!field_name || + flb_sds_len(ctx->creds->cred_source_format_subject_field) == 0) { + flb_plg_error(ctx->ins, "external_account: " + "credential_source.format.subject_token_field_name " + "is required when format.type is 'json'"); + flb_sds_destroy(raw); + return NULL; + } + + doc = yyjson_read(raw, flb_sds_len(raw), 0); + if (!doc) { + flb_plg_error(ctx->ins, "external_account: subject token from %s " + "is not valid JSON", source_label); + flb_sds_destroy(raw); + return NULL; + } + root = yyjson_doc_get_root(doc); + if (!yyjson_is_obj(root)) { + flb_plg_error(ctx->ins, "external_account: subject token from %s " + "is not a JSON object", source_label); + yyjson_doc_free(doc); + flb_sds_destroy(raw); + return NULL; + } + + v = yyjson_obj_get(root, field_name); + if (!v) { + flb_plg_error(ctx->ins, "external_account: subject token field " + "'%s' not found in %s", field_name, source_label); + } + else if (!yyjson_is_str(v)) { + flb_plg_error(ctx->ins, "external_account: '%s' field in subject " + "token from %s is not a string", + field_name, source_label); + } + else { + token = flb_sds_create(yyjson_get_str(v)); + } + + yyjson_doc_free(doc); + flb_sds_destroy(raw); + return token; +} + +static flb_sds_t read_subject_token_from_file(struct flb_stackdriver *ctx) +{ + int ret; + size_t buf_len; + char *buf = NULL; + flb_sds_t raw = NULL; + struct stat st; + const char *path; + + path = ctx->creds->cred_source_file; + if (!path || flb_sds_len(ctx->creds->cred_source_file) == 0) { + flb_plg_error(ctx->ins, "external_account: credential_source.file " + "is required"); + return NULL; + } + + if (stat(path, &st) == -1) { + flb_errno(); + flb_plg_error(ctx->ins, "external_account: cannot stat subject " + "token file: %s", path); + return NULL; + } + if (!S_ISREG(st.st_mode) && !S_ISLNK(st.st_mode)) { + flb_plg_error(ctx->ins, "external_account: subject token path is " + "not a regular file: %s", path); + return NULL; + } + if (st.st_size <= 0 || st.st_size > WIF_SUBJECT_TOKEN_MAX_SIZE) { + flb_plg_error(ctx->ins, "external_account: subject token file size " + "is invalid (%lld bytes): %s", + (long long) st.st_size, path); + return NULL; + } + + ret = flb_utils_read_file((char *) path, &buf, &buf_len); + if (ret != 0 || !buf || buf_len == 0) { + flb_plg_error(ctx->ins, "external_account: failed to read subject " + "token file: %s", path); + if (buf) { + flb_free(buf); + } + return NULL; + } + + raw = flb_sds_create_len(buf, buf_len); + flb_free(buf); + if (!raw) { + flb_errno(); + return NULL; + } + + return extract_subject_token(ctx, path, raw); +} + +/* + * Fetch the subject token from credential_source.url. Mirrors the file + * provider but with an HTTPS GET against the configured URL, optionally + * carrying caller-defined headers. + */ +static flb_sds_t read_subject_token_from_url(struct flb_stackdriver *ctx) +{ + int ret; + int port_n; + int h; + size_t b_sent = 0; + const char *url; + const char *path; + flb_sds_t hk; + flb_sds_t hv; + char *prot = NULL; + char *url_host = NULL; + char *url_port = NULL; + char *url_uri = NULL; + flb_sds_t raw = NULL; + struct flb_upstream *u; + struct flb_connection *conn = NULL; + struct flb_http_client *c = NULL; + + url = ctx->creds->cred_source_url; + if (!url || flb_sds_len(ctx->creds->cred_source_url) == 0) { + flb_plg_error(ctx->ins, "external_account: credential_source.url " + "is required"); + return NULL; + } + + u = wif_get_upstream(ctx, &ctx->wif_subject_url_u, url); + if (!u) { + return NULL; + } + + if (flb_utils_url_split(url, &prot, &url_host, &url_port, &url_uri) + != 0) { + flb_plg_error(ctx->ins, "external_account: failed to parse " + "credential_source.url: %s", url); + return NULL; + } + path = (url_uri && url_uri[0]) ? url_uri : "/"; + /* + * Default port: 443 for https, 80 for http. If a port was specified + * in the URL, that wins. + */ + if (url_port) { + port_n = atoi(url_port); + } + else if (prot && strcasecmp(prot, "http") == 0) { + port_n = 80; + } + else { + port_n = 443; + } + + conn = flb_upstream_conn_get(u); + if (!conn) { + flb_plg_error(ctx->ins, "external_account: failed to connect to " + "credential_source.url: %s", url); + goto cleanup; + } + + c = flb_http_client(conn, FLB_HTTP_GET, path, + NULL, 0, + url_host, port_n, NULL, 0); + if (!c) { + flb_plg_error(ctx->ins, "external_account: failed to create HTTP " + "client for credential_source.url"); + goto cleanup; + } + + flb_http_buffer_size(c, WIF_HTTP_BUFFER_SIZE); + flb_http_add_header(c, "User-Agent", 10, "Fluent-Bit", 10); + for (h = 0; h < ctx->creds->cred_source_headers_count; h++) { + hk = ctx->creds->cred_source_headers_keys[h]; + hv = ctx->creds->cred_source_headers_vals[h]; + if (hk && hv) { + flb_http_add_header(c, + hk, flb_sds_len(hk), + hv, flb_sds_len(hv)); + } + } + + ret = flb_http_do(c, &b_sent); + if (ret != 0) { + flb_plg_error(ctx->ins, "external_account: credential_source.url " + "HTTP request failed (ret=%d)", ret); + goto cleanup; + } + + if (c->resp.status < 200 || c->resp.status >= 300) { + flb_plg_error(ctx->ins, + "external_account: credential_source.url returned " + "HTTP %d: %.*s", + c->resp.status, + (int) c->resp.payload_size, + c->resp.payload ? c->resp.payload : ""); + goto cleanup; + } + if (!c->resp.payload || c->resp.payload_size == 0) { + flb_plg_error(ctx->ins, "external_account: credential_source.url " + "returned an empty response"); + goto cleanup; + } + + raw = flb_sds_create_len(c->resp.payload, c->resp.payload_size); + +cleanup: + if (c) { + flb_http_client_destroy(c); + } + if (conn) { + flb_upstream_conn_release(conn); + } + flb_free(prot); + flb_free(url_host); + flb_free(url_port); + flb_free(url_uri); + + if (!raw) { + return NULL; + } + return extract_subject_token(ctx, url, raw); +} + +/* + * Strict x-www-form-urlencoded encoder. flb_uri_encode does not escape + * '&', '=', '?' or '/', which are field separators in form bodies; using + * it here would silently corrupt STS requests if a value (e.g. workforce + * options JSON) contained any of them. Instead we percent-encode every + * byte that is not in the RFC 3986 unreserved set. + */ +static int wif_form_encode_value(flb_sds_t *buf, const char *src, size_t len) +{ + size_t i; + char hex[4]; + unsigned char c; + + for (i = 0; i < len; i++) { + c = (unsigned char) src[i]; + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~') { + if (flb_sds_cat_safe(buf, (const char *) &c, 1) != 0) { + return -1; + } + } + else { + snprintf(hex, sizeof(hex), "%%%02X", c); + if (flb_sds_cat_safe(buf, hex, 3) != 0) { + return -1; + } + } + } + return 0; +} + +/* Append a key/value pair as URL-encoded form data to buf */ +static int wif_form_append(flb_sds_t *buf, const char *key, const char *value) +{ + int ret; + size_t value_len; + + if (!value) { + return 0; + } + value_len = strlen(value); + if (value_len == 0) { + return 0; + } + + if (flb_sds_len(*buf) > 0) { + ret = flb_sds_cat_safe(buf, "&", 1); + if (ret != 0) { + return -1; + } + } + + ret = flb_sds_cat_safe(buf, key, strlen(key)); + if (ret != 0) { + return -1; + } + + ret = flb_sds_cat_safe(buf, "=", 1); + if (ret != 0) { + return -1; + } + + return wif_form_encode_value(buf, value, value_len); +} + +/* + * Lazily create an upstream for the given URL. STS and IAM are always + * https://, but credential_source.url legitimately allows plain http:// + * (sidecar token providers on localhost). Pass FLB_IO_TLS only when the + * URL is https://; flb_upstream_create_url() also force-enables TLS for + * https:// regardless, which keeps STS/IAM safe. + */ +static struct flb_upstream *wif_get_upstream(struct flb_stackdriver *ctx, + struct flb_upstream **slot, + const char *url) +{ + int io_flags; + struct flb_upstream *u; + + if (*slot) { + return *slot; + } + + io_flags = (strncasecmp(url, "https://", 8) == 0) ? FLB_IO_TLS : FLB_IO_TCP; + u = flb_upstream_create_url(ctx->config, url, io_flags, ctx->ins->tls); + if (!u) { + flb_plg_error(ctx->ins, "external_account: failed to create " + "upstream for %s", url); + return NULL; + } + + flb_stream_disable_async_mode(&u->base); + *slot = u; + return u; +} + +/* + * Performs the STS token exchange. On success, fills *out_access_token / + * *out_token_type / *out_expires_in (caller owns the sds strings) and + * returns 0. + */ +static int wif_sts_exchange(struct flb_stackdriver *ctx, + const char *subject_token, + const char *scope, + flb_sds_t *out_access_token, + flb_sds_t *out_token_type, + uint64_t *out_expires_in) +{ + int ret; + int rc = -1; + int port_n; + size_t b_sent = 0; + const char *token_url; + const char *path; + const char *universe; + const char *provider; + char *prot = NULL; + char *url_host = NULL; + char *url_port = NULL; + char *url_uri = NULL; + flb_sds_t default_token_url = NULL; + flb_sds_t api_client_header = NULL; + flb_sds_t body = NULL; + flb_sds_t options_json = NULL; + struct flb_upstream *u; + struct flb_connection *conn = NULL; + struct flb_http_client *c = NULL; + struct flb_oauth2 tmp_oauth = {0}; + + *out_access_token = NULL; + *out_token_type = NULL; + *out_expires_in = 0; + + token_url = ctx->creds->token_url; + if (!ctx->creds->token_url || flb_sds_len(ctx->creds->token_url) == 0) { + /* + * No explicit token_url in the credentials file. Fall back to the + * default STS endpoint, substituting universe_domain when set so + * non-googleapis.com clouds (sovereign / GDU) route correctly. + */ + universe = ctx->creds->universe_domain; + if (universe && flb_sds_len(ctx->creds->universe_domain) > 0 && + strcmp(universe, "googleapis.com") != 0) { + default_token_url = flb_sds_create_size(64); + if (!default_token_url) { + flb_errno(); + return -1; + } + if (!flb_sds_printf(&default_token_url, + "https://sts.%s/v1/token", universe)) { + flb_sds_destroy(default_token_url); + return -1; + } + token_url = default_token_url; + } + else { + token_url = FLB_STD_DEFAULT_STS_TOKEN_URL; + } + } + + u = wif_get_upstream(ctx, &ctx->wif_sts_u, token_url); + if (!u) { + return -1; + } + + body = flb_sds_create_size(1024); + if (!body) { + flb_errno(); + return -1; + } + + if (wif_form_append(&body, "audience", ctx->creds->audience) || + wif_form_append(&body, "grant_type", + FLB_STD_TOKEN_EXCHANGE_GRANT_TYPE) || + wif_form_append(&body, "requested_token_type", + FLB_STD_TOKEN_TYPE_ACCESS_TOKEN) || + wif_form_append(&body, "subject_token_type", + ctx->creds->subject_token_type) || + wif_form_append(&body, "subject_token", subject_token) || + wif_form_append(&body, "scope", scope)) { + flb_plg_error(ctx->ins, "external_account: failed to build STS " + "request body"); + goto cleanup; + } + + /* + * For workforce pools without a client_id, inject + * options={"userProject":""} into the form body. Build + * via yyjson_mut so a stray quote or backslash in the project + * value cannot produce malformed JSON. + */ + if (ctx->creds->workforce_pool_user_project && + flb_sds_len(ctx->creds->workforce_pool_user_project) > 0 && + (!ctx->creds->client_id || + flb_sds_len(ctx->creds->client_id) == 0)) { + yyjson_mut_doc *opts_doc; + yyjson_mut_val *opts_root; + char *opts_str; + + opts_doc = yyjson_mut_doc_new(NULL); + if (!opts_doc) { + flb_errno(); + goto cleanup; + } + opts_root = yyjson_mut_obj(opts_doc); + yyjson_mut_obj_add_str(opts_doc, opts_root, "userProject", + ctx->creds->workforce_pool_user_project); + yyjson_mut_doc_set_root(opts_doc, opts_root); + opts_str = yyjson_mut_write(opts_doc, 0, NULL); + if (!opts_str) { + yyjson_mut_doc_free(opts_doc); + goto cleanup; + } + options_json = flb_sds_create(opts_str); + free(opts_str); + yyjson_mut_doc_free(opts_doc); + if (!options_json) { + goto cleanup; + } + if (wif_form_append(&body, "options", options_json) != 0) { + goto cleanup; + } + } + + conn = flb_upstream_conn_get(u); + if (!conn) { + flb_plg_error(ctx->ins, "external_account: failed to connect to " + "STS endpoint %s", token_url); + goto cleanup; + } + + /* + * flb_http_client() needs the request-line URI (e.g. "/v1/token") and + * benefits from an explicit host so the Host header is correct. The + * upstream already parsed token_url for connection purposes, but we + * have to re-parse here to get the path component for the request line. + * Without it the request line becomes "POST HTTP/1.1" and Google's + * frontend rejects it with HTTP 411. + */ + if (flb_utils_url_split(token_url, &prot, &url_host, &url_port, + &url_uri) != 0) { + flb_plg_error(ctx->ins, "external_account: failed to parse " + "token_url: %s", token_url); + goto cleanup; + } + path = (url_uri && url_uri[0]) ? url_uri : "/"; + port_n = url_port ? atoi(url_port) : 443; + + c = flb_http_client(conn, FLB_HTTP_POST, path, + body, flb_sds_len(body), + url_host, port_n, NULL, 0); + if (!c) { + flb_plg_error(ctx->ins, "external_account: failed to create HTTP " + "client for STS request"); + goto cleanup; + } + + flb_http_buffer_size(c, WIF_HTTP_BUFFER_SIZE); + flb_http_add_header(c, + "Content-Type", 12, + "application/x-www-form-urlencoded", 33); + flb_http_add_header(c, "User-Agent", 10, "Fluent-Bit", 10); + + /* + * x-goog-api-client lets Google identify and attribute traffic from + * BYOID/external_account clients. Mirrors the Cloud SDK telemetry + * format: / google-byoid-sdk source/ + * sa-impersonation/ config-lifetime/. + */ + api_client_header = flb_sds_create_size(160); + if (api_client_header) { + provider = "file"; + if (ctx->creds->cred_source_url && + flb_sds_len(ctx->creds->cred_source_url) > 0) { + provider = "url"; + } + flb_sds_printf(&api_client_header, + "fluent-bit/%s google-byoid-sdk source/%s " + "sa-impersonation/%s config-lifetime/%s", + FLB_VERSION_STR, provider, + (ctx->creds->service_account_impersonation_url && + flb_sds_len(ctx->creds->service_account_impersonation_url) > 0) + ? "true" : "false", + (ctx->creds->sa_impersonation_lifetime_seconds > 0) + ? "true" : "false"); + flb_http_add_header(c, "x-goog-api-client", 17, + api_client_header, + flb_sds_len(api_client_header)); + } + + /* + * If client_id and client_secret are present, authenticate the STS + * call with HTTP Basic auth. + */ + if (ctx->creds->client_id && + flb_sds_len(ctx->creds->client_id) > 0 && + ctx->creds->client_secret && + flb_sds_len(ctx->creds->client_secret) > 0) { + ret = flb_http_basic_auth(c, + ctx->creds->client_id, + ctx->creds->client_secret); + if (ret != 0) { + flb_plg_error(ctx->ins, "external_account: failed to set " + "STS basic auth header"); + goto cleanup; + } + } + + ret = flb_http_do(c, &b_sent); + if (ret != 0) { + flb_plg_error(ctx->ins, "external_account: STS HTTP request " + "failed (ret=%d)", ret); + goto cleanup; + } + + if (c->resp.status != 200) { + flb_plg_error(ctx->ins, + "external_account: STS exchange returned HTTP %d: %.*s", + c->resp.status, + (int) c->resp.payload_size, + c->resp.payload ? c->resp.payload : ""); + goto cleanup; + } + + /* + * Reuse the existing oauth2 JSON parser to extract access_token, + * token_type and expires_in from the STS response. + */ + ret = flb_oauth2_parse_json_response(c->resp.payload, + c->resp.payload_size, + &tmp_oauth); + if (ret != 0) { + flb_plg_error(ctx->ins, "external_account: failed to parse STS " + "response"); + goto cleanup; + } + + /* + * RFC 8693 leaves expires_in == 0 undefined. expires_in is uint64_t, + * so this guard catches missing or zero values; either would cache a + * token that is treated as immediately expired and would trigger a + * hot refresh loop. + */ + if (tmp_oauth.expires_in <= 0) { + flb_plg_error(ctx->ins, "external_account: STS returned invalid " + "expires_in=%" PRIu64, tmp_oauth.expires_in); + goto cleanup; + } + + *out_access_token = tmp_oauth.access_token; + *out_token_type = tmp_oauth.token_type; + *out_expires_in = tmp_oauth.expires_in; + tmp_oauth.access_token = NULL; + tmp_oauth.token_type = NULL; + rc = 0; + +cleanup: + if (c) { + flb_http_client_destroy(c); + } + if (conn) { + flb_upstream_conn_release(conn); + } + if (tmp_oauth.access_token) { + flb_sds_destroy(tmp_oauth.access_token); + } + if (tmp_oauth.token_type) { + flb_sds_destroy(tmp_oauth.token_type); + } + if (options_json) { + flb_sds_destroy(options_json); + } + if (body) { + flb_sds_destroy(body); + } + if (default_token_url) { + flb_sds_destroy(default_token_url); + } + if (api_client_header) { + flb_sds_destroy(api_client_header); + } + flb_free(prot); + flb_free(url_host); + flb_free(url_port); + flb_free(url_uri); + return rc; +} + +/* + * Parse the impersonation response shape: + * {"accessToken":"ya29...","expireTime":"2024-01-01T00:00:00Z"} + */ +static int wif_parse_impersonation_response(struct flb_stackdriver *ctx, + const char *json_data, + size_t json_size, + flb_sds_t *out_access_token, + time_t *out_expires_at) +{ + int val_len; + char tm_buf[64]; + char *endp; + const char *val; + struct flb_tm tm = {0}; + yyjson_doc *doc = NULL; + yyjson_val *root; + yyjson_val *v; + + *out_access_token = NULL; + *out_expires_at = 0; + + doc = yyjson_read(json_data, json_size, 0); + if (!doc) { + flb_plg_error(ctx->ins, "external_account: impersonation response " + "is not valid JSON"); + return -1; + } + root = yyjson_doc_get_root(doc); + if (!yyjson_is_obj(root)) { + flb_plg_error(ctx->ins, "external_account: impersonation response " + "is not a JSON object"); + yyjson_doc_free(doc); + return -1; + } + + v = yyjson_obj_get(root, "accessToken"); + if (yyjson_is_str(v)) { + *out_access_token = flb_sds_create(yyjson_get_str(v)); + } + + /* + * IAM Credentials returns RFC3339 with a "Z" suffix: + * 2026-01-02T03:04:05Z. flb_strptime does not understand "%Z" portably, + * so we consume the timestamp prefix and rely on UTC. + */ + v = yyjson_obj_get(root, "expireTime"); + if (yyjson_is_str(v)) { + val = yyjson_get_str(v); + val_len = (int) yyjson_get_len(v); + if (val_len > 0 && val_len < (int) sizeof(tm_buf)) { + memcpy(tm_buf, val, val_len); + tm_buf[val_len] = '\0'; + endp = flb_strptime(tm_buf, "%Y-%m-%dT%H:%M:%S", &tm); + if (!endp) { + flb_plg_warn(ctx->ins, "external_account: cannot parse " + "impersonation expireTime '%s'", tm_buf); + } + else { + *out_expires_at = timegm(&tm.tm); + } + } + } + + yyjson_doc_free(doc); + + if (!*out_access_token) { + flb_plg_error(ctx->ins, "external_account: impersonation response " + "missing accessToken"); + return -1; + } + if (*out_expires_at == 0) { + flb_plg_warn(ctx->ins, "external_account: impersonation response " + "missing or unparseable expireTime; defaulting to " + "%d seconds", + FLB_STD_DEFAULT_IMPERSONATION_LIFETIME_SECONDS); + *out_expires_at = time(NULL) + + FLB_STD_DEFAULT_IMPERSONATION_LIFETIME_SECONDS; + } + + return 0; +} + +static int wif_impersonate(struct flb_stackdriver *ctx, + const char *federated_token, + const char *scope, + flb_sds_t *out_access_token, + time_t *out_expires_at) +{ + int ret; + int rc = -1; + int lifetime; + int port_n; + int d; + size_t b_sent = 0; + char lifetime_buf[32]; + const char *path; + char *prot = NULL; + char *url_host = NULL; + char *url_port = NULL; + char *url_uri = NULL; + char *body_str = NULL; + flb_sds_t body = NULL; + flb_sds_t auth_header = NULL; + struct flb_upstream *u; + struct flb_connection *conn = NULL; + struct flb_http_client *c = NULL; + yyjson_mut_doc *body_doc = NULL; + yyjson_mut_val *body_root; + yyjson_mut_val *scope_arr; + yyjson_mut_val *delegates_arr; + + *out_access_token = NULL; + *out_expires_at = 0; + + u = wif_get_upstream(ctx, &ctx->wif_iam_u, + ctx->creds->service_account_impersonation_url); + if (!u) { + return -1; + } + + lifetime = ctx->creds->sa_impersonation_lifetime_seconds; + if (lifetime <= 0) { + lifetime = FLB_STD_DEFAULT_IMPERSONATION_LIFETIME_SECONDS; + } + + /* + * Build the impersonation request body via yyjson_mut so that any + * special characters in scope or delegate emails are properly + * escaped. The IAM Credentials API expects: + * {"lifetime":"s","scope":[...],"delegates":[...]} + */ + body_doc = yyjson_mut_doc_new(NULL); + if (!body_doc) { + flb_errno(); + return -1; + } + body_root = yyjson_mut_obj(body_doc); + + snprintf(lifetime_buf, sizeof(lifetime_buf), "%ds", lifetime); + yyjson_mut_obj_add_str(body_doc, body_root, "lifetime", lifetime_buf); + + scope_arr = yyjson_mut_arr(body_doc); + yyjson_mut_arr_add_str(body_doc, scope_arr, scope); + yyjson_mut_obj_add_val(body_doc, body_root, "scope", scope_arr); + + /* + * Optional delegation chain. Each delegate must have + * iam.serviceAccountTokenCreator on the next entry; the impersonation + * URL's target service account ends the chain. + */ + if (ctx->creds->sa_impersonation_delegates_count > 0) { + delegates_arr = yyjson_mut_arr(body_doc); + for (d = 0; + d < ctx->creds->sa_impersonation_delegates_count; + d++) { + yyjson_mut_arr_add_str(body_doc, delegates_arr, + ctx->creds->sa_impersonation_delegates[d]); + } + yyjson_mut_obj_add_val(body_doc, body_root, "delegates", + delegates_arr); + } + + yyjson_mut_doc_set_root(body_doc, body_root); + body_str = yyjson_mut_write(body_doc, 0, NULL); + if (!body_str) { + flb_plg_error(ctx->ins, "external_account: failed to serialise " + "impersonation body"); + goto cleanup; + } + body = flb_sds_create(body_str); + if (!body) { + goto cleanup; + } + + auth_header = flb_sds_create_size(strlen(federated_token) + 16); + if (!auth_header) { + flb_errno(); + goto cleanup; + } + if (!flb_sds_printf(&auth_header, "Bearer %s", federated_token)) { + goto cleanup; + } + + conn = flb_upstream_conn_get(u); + if (!conn) { + flb_plg_error(ctx->ins, "external_account: failed to connect to " + "IAM Credentials endpoint"); + goto cleanup; + } + + if (flb_utils_url_split(ctx->creds->service_account_impersonation_url, + &prot, &url_host, &url_port, &url_uri) != 0) { + flb_plg_error(ctx->ins, "external_account: failed to parse " + "service_account_impersonation_url"); + goto cleanup; + } + path = (url_uri && url_uri[0]) ? url_uri : "/"; + port_n = url_port ? atoi(url_port) : 443; + + c = flb_http_client(conn, FLB_HTTP_POST, path, + body, flb_sds_len(body), + url_host, port_n, NULL, 0); + if (!c) { + flb_plg_error(ctx->ins, "external_account: failed to create HTTP " + "client for impersonation request"); + goto cleanup; + } + + flb_http_buffer_size(c, WIF_HTTP_BUFFER_SIZE); + flb_http_add_header(c, "Content-Type", 12, + "application/json", 16); + flb_http_add_header(c, "User-Agent", 10, "Fluent-Bit", 10); + flb_http_add_header(c, "Authorization", 13, + auth_header, flb_sds_len(auth_header)); + + ret = flb_http_do(c, &b_sent); + if (ret != 0) { + flb_plg_error(ctx->ins, "external_account: impersonation HTTP " + "request failed (ret=%d)", ret); + goto cleanup; + } + + if (c->resp.status != 200) { + flb_plg_error(ctx->ins, + "external_account: impersonation returned HTTP %d: %.*s", + c->resp.status, + (int) c->resp.payload_size, + c->resp.payload ? c->resp.payload : ""); + goto cleanup; + } + + rc = wif_parse_impersonation_response(ctx, + c->resp.payload, + c->resp.payload_size, + out_access_token, + out_expires_at); + +cleanup: + if (c) { + flb_http_client_destroy(c); + } + if (conn) { + flb_upstream_conn_release(conn); + } + if (auth_header) { + flb_sds_destroy(auth_header); + } + if (body) { + flb_sds_destroy(body); + } + if (body_str) { + free(body_str); + } + if (body_doc) { + yyjson_mut_doc_free(body_doc); + } + flb_free(prot); + flb_free(url_host); + flb_free(url_port); + flb_free(url_uri); + return rc; +} + +int stackdriver_external_account_read_token(struct flb_stackdriver *ctx) +{ + int rc = -1; + flb_sds_t subject_token = NULL; + flb_sds_t federated_access_token = NULL; + flb_sds_t federated_token_type = NULL; + flb_sds_t final_access_token = NULL; + flb_sds_t final_token_type = NULL; + uint64_t federated_expires_in = 0; + time_t final_expires_at = 0; + time_t now; + const char *sts_scope; + const char *impersonation_url; + int impersonating; + + if (!ctx->creds) { + flb_plg_error(ctx->ins, "external_account: missing credentials"); + return -1; + } + if (!ctx->creds->audience || + flb_sds_len(ctx->creds->audience) == 0 || + !ctx->creds->subject_token_type || + flb_sds_len(ctx->creds->subject_token_type) == 0) { + flb_plg_error(ctx->ins, "external_account: 'audience' and " + "'subject_token_type' are required"); + return -1; + } + + impersonation_url = ctx->creds->service_account_impersonation_url; + impersonating = (impersonation_url && + flb_sds_len(ctx->creds->service_account_impersonation_url) > 0); + + /* + * When impersonating, the federated token must carry cloud-platform so + * it can call IAM Credentials. Otherwise we ask STS directly for the + * narrower scope used by the Stackdriver client. + */ + sts_scope = impersonating ? FLB_STD_IAM_SCOPE : FLB_STD_SCOPE; + + if (ctx->creds->cred_source_file && + flb_sds_len(ctx->creds->cred_source_file) > 0) { + subject_token = read_subject_token_from_file(ctx); + } + else if (ctx->creds->cred_source_url && + flb_sds_len(ctx->creds->cred_source_url) > 0) { + subject_token = read_subject_token_from_url(ctx); + } + else { + flb_plg_error(ctx->ins, "external_account: credential_source must " + "provide either 'file' or 'url'"); + return -1; + } + if (!subject_token) { + return -1; + } + + rc = wif_sts_exchange(ctx, + subject_token, + sts_scope, + &federated_access_token, + &federated_token_type, + &federated_expires_in); + if (rc != 0 || !federated_access_token) { + goto cleanup; + } + + now = time(NULL); + if (!impersonating) { + final_access_token = federated_access_token; + federated_access_token = NULL; + final_token_type = federated_token_type; + federated_token_type = NULL; + final_expires_at = now + (time_t) federated_expires_in; + } + else { + rc = wif_impersonate(ctx, + federated_access_token, + FLB_STD_SCOPE, + &final_access_token, + &final_expires_at); + if (rc != 0 || !final_access_token) { + rc = -1; + goto cleanup; + } + final_token_type = flb_sds_create("Bearer"); + if (!final_token_type) { + flb_errno(); + rc = -1; + goto cleanup; + } + } + + /* Publish the final token into the oauth2 context */ + if (ctx->o->access_token) { + flb_sds_destroy(ctx->o->access_token); + } + if (ctx->o->token_type) { + flb_sds_destroy(ctx->o->token_type); + } + ctx->o->access_token = final_access_token; + ctx->o->token_type = final_token_type; + ctx->o->expires_at = final_expires_at; + ctx->o->expires_in = (final_expires_at > now) ? + (uint64_t) (final_expires_at - now) : 0; + final_access_token = NULL; + final_token_type = NULL; + + rc = 0; + +cleanup: + if (subject_token) { + flb_sds_destroy(subject_token); + } + if (federated_access_token) { + flb_sds_destroy(federated_access_token); + } + if (federated_token_type) { + flb_sds_destroy(federated_token_type); + } + if (final_access_token) { + flb_sds_destroy(final_access_token); + } + if (final_token_type) { + flb_sds_destroy(final_token_type); + } + return rc; +} diff --git a/plugins/out_stackdriver/stackdriver_external_account.h b/plugins/out_stackdriver/stackdriver_external_account.h new file mode 100644 index 00000000000..b351b151fca --- /dev/null +++ b/plugins/out_stackdriver/stackdriver_external_account.h @@ -0,0 +1,47 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* Fluent Bit + * ========== + * Copyright (C) 2015-2026 The Fluent Bit Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef FLUENT_BIT_STACKDRIVER_EXTERNAL_ACCOUNT_H +#define FLUENT_BIT_STACKDRIVER_EXTERNAL_ACCOUNT_H + +#include "stackdriver.h" + +/* + * Returns FLB_TRUE when ctx->creds describes an "external_account" credential + * file (Workload Identity Federation). + */ +int stackdriver_external_account_is_configured(struct flb_stackdriver *ctx); + +/* + * Implements the Workload Identity Federation flow: + * + * 1. Read the subject token from credential_source.file + * 2. POST it to the STS token URL to obtain a federated access token + * 3. If service_account_impersonation_url is set, POST the federated + * token to the IAM Credentials generateAccessToken endpoint to obtain + * a Google service-account access token + * + * The resulting access_token / token_type / expires_at are stored in + * ctx->o so that the existing get_google_token() machinery can use them. + * + * Returns 0 on success, -1 on failure. + */ +int stackdriver_external_account_read_token(struct flb_stackdriver *ctx); + +#endif From ee6e7167a90128cf2323b04d6a52daab81b39684 Mon Sep 17 00:00:00 2001 From: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:01:20 +0200 Subject: [PATCH 2/2] tests: out_stackdriver: cover external_account credential parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add runtime tests for the new external_account (Workload Identity Federation) credential handling in out_stackdriver: - external_account_with_impersonation - external_account_no_impersonation - external_account_url_source - external_account_rejects_executable_source - external_account_rejects_aws_source - external_account_rejects_certificate_source - external_account_rejects_missing_audience - external_account_rejects_missing_subject_token_type - external_account_rejects_workforce_mismatch Each test boots a fluent-bit instance with a fixture credentials JSON under tests/runtime/data/stackdriver/, attaches a formatter test-mode callback that flips a flag on first format, ingests one record, and asserts whether the formatter ran. Accepted shapes must reach the formatter (init succeeded, output enabled). Rejected shapes must not: flb_start must fail and the formatter callback must never fire. URL-sourced credential_source is verified at init time only — token acquisition is lazy (first flush) and the runtime test does not stand up a local HTTP server. End-to-end coverage of the URL path (real HTTP GET, STS exchange, log delivery) is exercised by an out-of-tree smoke harness and is not part of ctest. Signed-off-by: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com> --- ...iver-credentials-external-account-aws.json | 11 ++ ...ver-credentials-external-account-cert.json | 12 ++ ...ver-credentials-external-account-exec.json | 12 ++ ...dentials-external-account-no-audience.json | 11 ++ ...als-external-account-no-impersonation.json | 12 ++ ...xternal-account-no-subject-token-type.json | 11 ++ ...iver-credentials-external-account-url.json | 13 ++ ...s-external-account-workforce-mismatch.json | 13 ++ ...ckdriver-credentials-external-account.json | 13 ++ tests/runtime/out_stackdriver.c | 187 ++++++++++++++++++ 10 files changed, 295 insertions(+) create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-aws.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-cert.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-exec.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-audience.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-impersonation.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-subject-token-type.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-url.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account-workforce-mismatch.json create mode 100644 tests/runtime/data/stackdriver/stackdriver-credentials-external-account.json diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-aws.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-aws.json new file mode 100644 index 00000000000..8fe965f9445 --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-aws.json @@ -0,0 +1,11 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "environment_id": "aws1", + "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone", + "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-cert.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-cert.json new file mode 100644 index 00000000000..32a6f3d4096 --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-cert.json @@ -0,0 +1,12 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:mtls", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "certificate": { + "use_default_certificate_config": true, + "trust_chain_path": "/etc/ssl/certs/trust-chain.pem" + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-exec.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-exec.json new file mode 100644 index 00000000000..ba561d4aefd --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-exec.json @@ -0,0 +1,12 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "executable": { + "command": "/path/to/helper --json", + "timeout_millis": 5000 + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-audience.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-audience.json new file mode 100644 index 00000000000..f2e366af006 --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-audience.json @@ -0,0 +1,11 @@ +{ + "type": "external_account", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "/var/run/service-account/token", + "format": { + "type": "text" + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-impersonation.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-impersonation.json new file mode 100644 index 00000000000..6fc3ac24d75 --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-impersonation.json @@ -0,0 +1,12 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "/var/run/service-account/token", + "format": { + "type": "text" + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-subject-token-type.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-subject-token-type.json new file mode 100644 index 00000000000..c3997ad0aba --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-no-subject-token-type.json @@ -0,0 +1,11 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "/var/run/service-account/token", + "format": { + "type": "text" + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-url.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-url.json new file mode 100644 index 00000000000..144943c03a0 --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-url.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "url": "http://localhost:5000/token", + "format": { + "type": "json", + "subject_token_field_name": "id_token" + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-workforce-mismatch.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-workforce-mismatch.json new file mode 100644 index 00000000000..aa656a9513f --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account-workforce-mismatch.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "workforce_pool_user_project": "billing-project", + "credential_source": { + "file": "/var/run/service-account/token", + "format": { + "type": "text" + } + } +} diff --git a/tests/runtime/data/stackdriver/stackdriver-credentials-external-account.json b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account.json new file mode 100644 index 00000000000..7312a703143 --- /dev/null +++ b/tests/runtime/data/stackdriver/stackdriver-credentials-external-account.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "/var/run/service-account/token", + "format": { + "type": "text" + } + }, + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/logwriter@fluent-bit.iam.gserviceaccount.com:generateAccessToken" +} diff --git a/tests/runtime/out_stackdriver.c b/tests/runtime/out_stackdriver.c index 2bd79f31aaf..e55dde79590 100644 --- a/tests/runtime/out_stackdriver.c +++ b/tests/runtime/out_stackdriver.c @@ -29,6 +29,26 @@ FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials.json" #define STACKDRIVER_DATA_PATH "/data/stackdriver" +/* external_account / Workload Identity Federation fixtures */ +#define EXT_ACCT_CREDS_FILE \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account.json" +#define EXT_ACCT_CREDS_NO_IMP \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-no-impersonation.json" +#define EXT_ACCT_CREDS_URL \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-url.json" +#define EXT_ACCT_CREDS_EXEC \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-exec.json" +#define EXT_ACCT_CREDS_NO_AUDIENCE \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-no-audience.json" +#define EXT_ACCT_CREDS_NO_SUBJECT_TYPE \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-no-subject-token-type.json" +#define EXT_ACCT_CREDS_WORKFORCE_MISMATCH \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-workforce-mismatch.json" +#define EXT_ACCT_CREDS_AWS \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-aws.json" +#define EXT_ACCT_CREDS_CERT \ + FLB_TESTS_DATA_PATH "/data/stackdriver/stackdriver-credentials-external-account-cert.json" + /* JSON payload example */ #include "data/stackdriver/json.h" #include "data/stackdriver/stackdriver_test_operation.h" @@ -6537,6 +6557,162 @@ void flb_test_non_scalar_payload_with_residual_fields() flb_destroy(ctx); } +/* + * external_account / Workload Identity Federation parser tests. + * + * These run the plugin with no network access, so they cannot exercise + * the STS/IAM round-trip. Instead they verify that the credentials file + * parser handles each variant correctly: accepted variants leave the + * plugin reachable through the format callback; rejected variants fail + * init so the format callback never runs. + */ +static int ext_acct_format_called; + +static void cb_ext_acct_mark(void *ctx, int ffd, + int res_ret, void *res_data, size_t res_size, + void *data) +{ + ext_acct_format_called = FLB_TRUE; + flb_sds_destroy(res_data); +} + +static int run_external_account_init(const char *creds_path) +{ + int ret; + int in_ffd; + int out_ffd; + flb_ctx_t *ctx; + static const char dummy_record[] = "[1448403340, {\"k\":\"v\"}]"; + + ext_acct_format_called = FLB_FALSE; + + ctx = flb_create(); + flb_service_set(ctx, "flush", "1", "grace", "1", NULL); + + in_ffd = flb_input(ctx, (char *) "lib", NULL); + flb_input_set(ctx, in_ffd, "tag", "test", NULL); + + out_ffd = flb_output(ctx, (char *) "stackdriver", NULL); + flb_output_set(ctx, out_ffd, + "match", "test", + "google_service_credentials", creds_path, + "resource", "global", + "export_to_project_id", "fluent-bit-test-project", + NULL); + + flb_output_set_test(ctx, out_ffd, "formatter", + cb_ext_acct_mark, NULL, NULL); + + ret = flb_start(ctx); + if (ret == 0) { + flb_lib_push(ctx, in_ffd, (char *) dummy_record, + sizeof(dummy_record) - 1); + sleep(1); + flb_stop(ctx); + } + + flb_destroy(ctx); + return ret; +} + +void flb_test_external_account_with_impersonation() +{ + int ret; + + ret = run_external_account_init(EXT_ACCT_CREDS_FILE); + TEST_CHECK(ret == 0); + TEST_CHECK(ext_acct_format_called == FLB_TRUE); +} + +void flb_test_external_account_no_impersonation() +{ + int ret; + + ret = run_external_account_init(EXT_ACCT_CREDS_NO_IMP); + TEST_CHECK(ret == 0); + TEST_CHECK(ext_acct_format_called == FLB_TRUE); +} + +void flb_test_external_account_url_source() +{ + int ret; + + /* + * URL-sourced credential_source must initialize successfully. Token + * acquisition is lazy (first flush), and this test does not stand up a + * local HTTP server — it only verifies that init succeeds and the + * formatter callback fires for the first record. End-to-end coverage + * (real HTTP GET, STS exchange, log delivery) is exercised by the + * out-of-tree smoke harness in tmp/run_url_test.sh. + */ + ret = run_external_account_init(EXT_ACCT_CREDS_URL); + TEST_CHECK(ret == 0); + TEST_CHECK(ext_acct_format_called == FLB_TRUE); +} + +void flb_test_external_account_rejects_executable_source() +{ + int ret; + + /* + * Executable credential_source is unsupported and must be rejected at + * init time, not silently accepted with the output disabled. + */ + ret = run_external_account_init(EXT_ACCT_CREDS_EXEC); + TEST_CHECK(ret != 0); + TEST_CHECK(ext_acct_format_called == FLB_FALSE); +} + +void flb_test_external_account_rejects_aws_source() +{ + int ret; + + ret = run_external_account_init(EXT_ACCT_CREDS_AWS); + TEST_CHECK(ret != 0); + TEST_CHECK(ext_acct_format_called == FLB_FALSE); +} + +void flb_test_external_account_rejects_certificate_source() +{ + int ret; + + ret = run_external_account_init(EXT_ACCT_CREDS_CERT); + TEST_CHECK(ret != 0); + TEST_CHECK(ext_acct_format_called == FLB_FALSE); +} + +void flb_test_external_account_rejects_missing_audience() +{ + int ret; + + ret = run_external_account_init(EXT_ACCT_CREDS_NO_AUDIENCE); + TEST_CHECK(ret != 0); + TEST_CHECK(ext_acct_format_called == FLB_FALSE); +} + +void flb_test_external_account_rejects_missing_subject_token_type() +{ + int ret; + + ret = run_external_account_init(EXT_ACCT_CREDS_NO_SUBJECT_TYPE); + TEST_CHECK(ret != 0); + TEST_CHECK(ext_acct_format_called == FLB_FALSE); +} + +void flb_test_external_account_rejects_workforce_mismatch() +{ + int ret; + + /* + * workforce_pool_user_project is only valid with workforce-pool + * audiences. Setting it on a workload-pool audience must be a hard + * config error, not a silent no-op. + */ + ret = run_external_account_init(EXT_ACCT_CREDS_WORKFORCE_MISMATCH); + TEST_CHECK(ret != 0); + TEST_CHECK(ext_acct_format_called == FLB_FALSE); +} + /* Test list */ TEST_LIST = { {"severity_multi_entries", flb_test_multi_entries_severity }, @@ -6671,5 +6847,16 @@ TEST_LIST = { {"string_text_payload_with_mismatched_text_payload_key", flb_test_string_text_payload_with_mismatched_text_payload_key}, {"string_text_payload_with_residual_fields", flb_test_string_text_payload_with_residual_fields}, {"non_scalar_payload_with_residual_fields", flb_test_non_scalar_payload_with_residual_fields}, + + /* external_account / Workload Identity Federation */ + {"external_account_with_impersonation", flb_test_external_account_with_impersonation}, + {"external_account_no_impersonation", flb_test_external_account_no_impersonation}, + {"external_account_url_source", flb_test_external_account_url_source}, + {"external_account_rejects_executable_source", flb_test_external_account_rejects_executable_source}, + {"external_account_rejects_aws_source", flb_test_external_account_rejects_aws_source}, + {"external_account_rejects_certificate_source", flb_test_external_account_rejects_certificate_source}, + {"external_account_rejects_missing_audience", flb_test_external_account_rejects_missing_audience}, + {"external_account_rejects_missing_subject_token_type", flb_test_external_account_rejects_missing_subject_token_type}, + {"external_account_rejects_workforce_mismatch", flb_test_external_account_rejects_workforce_mismatch}, {NULL, NULL} };