Skip to content

Add Clone API Key endpoint #142633

Merged
tvernum merged 19 commits intoelastic:mainfrom
tvernum:security/clone-api-key
Mar 10, 2026
Merged

Add Clone API Key endpoint #142633
tvernum merged 19 commits intoelastic:mainfrom
tvernum:security/clone-api-key

Conversation

@tvernum
Copy link
Copy Markdown
Contributor

@tvernum tvernum commented Feb 18, 2026

Adds a new API endpoint that allows an appropriately privileged user
to clone an existing API Key, provided that user has the API Key
credential.

This is roughly equivalent to the Grant API Key endpoint, which
creates API Keys on behalf of a user (given the user's credential).

This new endpoint is designed for cases where the Grant API Key
endpoint would normally be used, except that the authenticating
credential is an API Key. Since we don't support derived API Keys
(that is, API Keys creating API Keys) we instead allow the client
(e.g. Kibana) to clone the provided API Key for its own internal use.

Resolves: #59304

Adds a new API endpoint that allows an appropriately privileged user
to clone an existing API Key, provided that user has the API Key
credential.

This is roughly equivalent to the Grant API Key endpoint, which
creates API Keys on behalf of a user (given the user's credential).

This new endpoint is designed for cases where the Grant API Key
endpoint would normally be used, except that the authenticating
credential is an API Key. Since we don't support derived API Keys
(that is, API Keys creating API Keys) we instead allow the client
(e.g. Kibana) to clone the provided API Key for its own internal use.
@tvernum tvernum added >enhancement :Security/Authentication Logging in, Usernames/passwords, Realms (Native/LDAP/AD/SAML/PKI/etc) labels Feb 18, 2026
@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

Hi @tvernum, I've created a changelog YAML for you.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 18, 2026

🔍 Preview links for changed docs

@github-actions
Copy link
Copy Markdown
Contributor

ℹ️ Important: Docs version tagging

👋 Thanks for updating the docs! Just a friendly reminder that our docs are now cumulative. This means all 9.x versions are documented on the same page and published off of the main branch, instead of creating separate pages for each minor version.

We use applies_to tags to mark version-specific features and changes.

Expand for a quick overview

When to use applies_to tags:

✅ At the page level to indicate which products/deployments the content applies to (mandatory)
✅ When features change state (e.g. preview, ga) in a specific version
✅ When availability differs across deployments and environments

What NOT to do:

❌ Don't remove or replace information that applies to an older version
❌ Don't add new information that applies to a specific version without an applies_to tag
❌ Don't forget that applies_to tags can be used at the page, section, and inline level

🤔 Need help?

@tvernum
Copy link
Copy Markdown
Contributor Author

tvernum commented Feb 23, 2026

Purpose

This PR adds a Clone API Key REST API so that a user with the right privileges can create a new API key that has the same role descriptors and owner as an existing API Key. The source API Key is identified by supplying its encoded credential in the request.

Use case: When the caller would normally use Grant API Key (creating an API key on behalf of a user), but the authenticating credential is itself an API key.
Elasticsearch does not support “derived” API keys (an API key creating another API key). This endpoint lets a client (e.g. Kibana) clone the user’s API key for its own internal use instead of using Grant API Key with that key (which doesn't work)

REST API

  • Path: /_security/api_key/clone
  • Methods: POST, PUT (same as Grant API Key)
  • Body : Required, see below
  • Query Parameters: Optional refresh (true | false | wait_for).
  • Headers: Standard authentication headers for the orchestrating user (e.g. Kibana service account)

Request body

Field Required Description
api_key Yes Encoded credential of the source key (i.e. Base64 of id + ":" + secret).
name Yes Name for the new (cloned) key. Same rules as Create API Key (e.g. max 256 chars, no leading _).
expiration No Omit = same expiry as source. null = no expiry. Value (e.g. "30d", "1h") = new expiry from now.
metadata No Omit = copy from source (then server adds _cloned_from). Present (including {}) = replace with this object (server still adds _cloned_from). Request must not include _cloned_from (reserved).

Examples:

Minimal required fields

{
  "api_key": "VnVhQ2ZHY0RvZGZWekVXTmJPRVp3YUdwe...",
  "name": "my-cloned-key"
}

All supported fields

{
  "api_key": "VnVhQ2ZHY0RvZGZWekVXTmJPRVp3YUdwe...",
  "name": "my-cloned-key",
  "expiration": "30d",
  "metadata": {
    "environment": "staging",
    "purpose": "CI pipeline"
  }
}

Response

Same as Create API Key / Grant API Key: CreateApiKeyResponse with id, name, api_key, expiration, encoded.

Example:

{
  "id": "A1b2C3d4E5f6G7h8I9j0K",
  "name": "my-cloned-key",
  "api_key": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
  "expiration": 1740268800000,
  "encoded": "QTFiOEMzZDRFNWY2RzdoOEo5ajBLOmExYjJjM2Q0ZTVmNmc3aDhpOWowazFsMm0zbjRvNXA2"
}

3. Behaviour

  • Source key: Must be a valid, non-expired, non-invalidated REST API key. Only REST API keys can be cloned; cross-cluster or other types are rejected. The credential (including the secret) is required (proof of possession), you cannot clone an API Key purely by id
  • New key: Has a new id and secret (that is, is a new credential); has the same privileges (role_descriptors) as the source (this includes both the original owner's limited roles, and any role descriptors that were assigned at creation). The creator is the source key’s creator (not the user calling the clone endpoint).
  • Expiration: By default the new API Key expires at the same time (that is, the same point in time, not the same duration) as the source API Key, but this can be overridden by the caller.
    It is valid for an expiring API Key to be cloned into a non-expiring API Key (or an API key with a long expiry).
    This behaviour allows for rotating API Keys where an API Key can be created with an explicit expiry (e.g. 90 days) and then, if it is still in use and needed an appropriately privileged user can create a clone with a new expiry (another 90 days).
  • Metadata: By default copied from source, but may be fully replaced by request body.
    In all cases the server adds _cloned_from = source key id.
    Request metadata must not use keys under the reserved _ prefix (including _cloned_from).
  • Errors:
    • Invalid/missing credential or bad format → 400
    • Invalid/expired source key or unauthorized caller → 401/403 (REST layer maps UNAUTHORIZED to FORBIDDEN for consistency with Grant).
  • Serverless: This API is INTERNAL in Serverless (as per Grant API Key) and the new privilege is not available for use in roles.

4. Security model

  • Privilege: New cluster privilege clone_api_key, action cluster:admin/xpack/security/api_key/clone. Same pattern as grant_api_key. manage_security also allows the action.
  • Kibana: The Kibana system reserved role gets clone_api_key so Kibana can call the endpoint when the user authenticates with an API key.
  • manage_own_api_key: Explicitly does not allow clone (same as Grant): ManageOwnApiKeyClusterPrivilege returns false for CloneApiKeyRequest, so a dedicated clone_api_key (or manage_security) is required.
  • Authorization: Caller must have clone_api_key (or broader). The source key is not authorised by a separate index/key privilege; possession of the API key credential is the proof that the caller is allowed to clone that key.
  • Sensitive data: The api_key body field is filtered from logs and audit via RestRequestFilter (getFilteredFields() returns "api_key"), same as password/access_token for Grant API Key.
  • Operator Privileges: The new action is not operator-only in stateful, but is INTERNAL in Serverless.

5. Implementation details (for reviewers)

  • Request id: CloneApiKeyRequest generates the new key’s id in the no-arg constructor (UUIDs.base64UUID()) so that id is fixed for the request and can be audited; the actual document is written with this id in ApiKeyService.createApiKeyFromClone.
  • Validation reuse: API key name and metadata validation are centralised in Validation.ApiKey (validateName, validateMetadata). AbstractCreateApiKeyRequest now uses these helpers instead of inlining checks; CloneApiKeyRequest uses the same validation.
  • ApiKeyService refactor for clone:
    • loadApiKeyDoc: New helper that only loads the API key document (with cache).
    • loadApiKeyAndValidateCredentials now returns AuthenticationResult<Tuple2<User, ApiKeyDoc>> and uses loadApiKeyDoc plus credential validation, so both auth and clone can reuse it (clone needs the doc for role descriptors and creator).
    • AuthenticationResult.map(Function): New method to transform the success value; used so tryAuthenticate can keep returning AuthenticationResult<User> while internally using the tuple (e.g. response.map(Tuple::v1)).
    • validateCloneableApiKeyAndGetDoc: Ensures the key is REST type, then runs load + credential validation and returns the ApiKeyDoc for the clone path.
    • newDocument / creator: A new overload of newDocument takes a Map<String, Object> creator instead of Authentication. creatorMapFromAuthentication builds that map; the existing addCreator(builder, authentication) now delegates to it. Clone passes the source key’s creator map so the cloned key has the same creator.
    • Role descriptors: The internal helper that writes role descriptors is renamed to addAssignedRoleDescriptors for clarity (and now takes Collection).
  • REST: RestCloneApiKeyAction implements RestRequestFilter and filters api_key. UNAUTHORIZED from the transport layer is converted to FORBIDDEN in the REST response (same pattern as RestGrantApiKeyAction). Scope is @ServerlessScope(Scope.INTERNAL).
  • Docs: clone_api_key is documented in security-privileges.md with {applies_to}serverless: unavailable.

This adds a "create_apikey" security config change event when an API
Key is cloned.

It also adds tests for auditing of these events and the general audit
event when the action is executed (including request filtering).

In doing so, we moved `ApiKeyCredentials` into x-pack core so that
the `CloneApiKeyRequest` could parse the credentials in the request
and the source API Key Id could be included in the
`security_config_change` audit record
@elasticsearchmachine elasticsearchmachine added the serverless-linked Added by automation, don't add manually label Feb 25, 2026
@tvernum tvernum marked this pull request as ready for review February 25, 2026 05:17
@tvernum tvernum requested review from a team as code owners February 25, 2026 05:17
@tvernum tvernum requested a review from kc13greiner February 25, 2026 05:17
@elasticsearchmachine elasticsearchmachine added the Team:Security Meta label for security team label Feb 25, 2026
@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

Pinging @elastic/es-security (Team:Security)

"type": "enum",
"options": ["true", "false", "wait_for"],
"default": "false",
"description": "If `true` (the default) then refresh the affected shards to make this operation visible to search, if `wait_for` then wait for a refresh to make this operation visible to search, if `false` then do nothing with refreshes."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wait_for seem to be default value in stateful and true (immediate) in serverless:

return DiscoveryNode.isStateless(settings) ? RefreshPolicy.IMMEDIATE : RefreshPolicy.WAIT_UNTIL;

We can express this difference in the description, but "default": "false" is wrong in both cases. Probably best omitting default altogether?

@kc13greiner kc13greiner requested review from jeramysoucy and removed request for kc13greiner February 27, 2026 21:28
if (result.isAuthenticated()) {
return result.getValue().v2();
} else {
throw new ElasticsearchSecurityException(result.getMessage(), result.getException());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user provides an API key that does not exist, this validation will return internal server error (500). This is because there is no root cause and ElasticsearchSecurityException defaults to 500 status. I think we need to handle unsuccessful cases similar as we do in tryAuthenticate where we wrap terminating authentication results with Exceptions.authenticationError(result.getMessage())

{
    "error": {
        "root_cause": [
            {
                "type": "security_exception",
                "reason": "unable to find apikey with id A1b8C3d4E5f6G7h8J9j0K"
            }
        ],
        "type": "security_exception",
        "reason": "unable to find apikey with id A1b8C3d4E5f6G7h8J9j0K"
    },
    "status": 500
}

Logged suppressed warning:

[2026-03-02T10:13:51,336][WARN ][r.suppressed             ] [runTask-0] path: /_security/api_key/clone, params: {}, status: 500 org.elasticsearch.ElasticsearchSecurityException: unable to find apikey with id A1b8C3d4E5f6G7h8J9j0K
        at org.elasticsearch.security@9.4.0-SNAPSHOT/org.elasticsearch.xpack.security.authc.ApiKeyService.lambda$validateCloneableApiKeyAndGetDoc$39(ApiKeyService.java:1542)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListenerImplementations$MappedActionListener.onResponse(ActionListenerImplementations.java:105)
        at org.elasticsearch.security@9.4.0-SNAPSHOT/org.elasticsearch.xpack.security.authc.ApiKeyService.lambda$loadApiKeyAndValidateCredentials$31(ApiKeyService.java:1322)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListenerImplementations$DelegatingFailureActionListener.onResponse(ActionListenerImplementations.java:233)
        at org.elasticsearch.security@9.4.0-SNAPSHOT/org.elasticsearch.xpack.security.authc.ApiKeyService.lambda$loadApiKeyDoc$25(ApiKeyService.java:1279)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListener$2.onResponse(ActionListener.java:258)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.support.ContextPreservingActionListener.onResponse(ContextPreservingActionListener.java:33)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListener$3.onResponse(ActionListener.java:416)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.tasks.TaskManager$1.onResponse(TaskManager.java:223)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.tasks.TaskManager$1.onResponse(TaskManager.java:217)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListenerImplementations$RunBeforeActionListener.onResponse(ActionListenerImplementations.java:350)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListener$3.onResponse(ActionListener.java:416)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.support.ContextPreservingActionListener.onResponse(ContextPreservingActionListener.java:33)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListenerImplementations$MappedActionListener.onResponse(ActionListenerImplementations.java:111)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.action.ActionListenerResponseHandler.handleResponse(ActionListenerResponseHandler.java:49)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.transport.TransportService$ContextRestoreResponseHandler.handleResponse(TransportService.java:1514)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.transport.TransportService$DirectResponseChannel.processResponse(TransportService.java:1612)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.transport.TransportService$DirectResponseChannel$1.doRun(TransportService.java:1592)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.common.util.concurrent.ThreadContext$ContextPreservingAbstractRunnable.doRun(ThreadContext.java:1114)
        at org.elasticsearch.server@9.4.0-SNAPSHOT/org.elasticsearch.common.util.concurrent.AbstractRunnable.run(AbstractRunnable.java:27)
        at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
        at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
        at java.base/java.lang.Thread.run(Thread.java:1474)


Copy link
Copy Markdown
Contributor

@slobodanadamovic slobodanadamovic Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API also returns 500 when attempting to clone cross-cluster API keys:

{
    "error": {
        "root_cause": [
            {
                "type": "security_exception",
                "reason": "authentication expected API key type of [rest], but API key [Ba3RrZwB7lWGJlxHOUCx] has type [cross_cluster]"
            }
        ],
        "type": "security_exception",
        "reason": "authentication expected API key type of [rest], but API key [Ba3RrZwB7lWGJlxHOUCx] has type [cross_cluster]"
    },
    "status": 500
}

ResponseException.class,
() -> cloneApiKey(CLONE_API_KEY_USER, invalidEncoded, randomAlphaOfLength(12), Map.of())
);
assertThat(e.getResponse().getStatusLine().getStatusCode(), greaterThanOrEqualTo(400));
Copy link
Copy Markdown
Contributor

@slobodanadamovic slobodanadamovic Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest checking actual expected status code (same as done in other places) rather than greaterThanOrEqualTo(400). This test would have caught the issue mentioned in #142633 (comment)

@tvernum tvernum requested a review from slobodanadamovic March 5, 2026 03:02
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 5, 2026

Vale Linting Results

Summary: 1 suggestion found

💡 Suggestions (1)
File Line Rule Message
docs/reference/elasticsearch/security-privileges.md 64 Elastic.Clone Use clone only when referring to cloning a GitHub repository or creating a copy that is linked to the original. Often confused with 'copy' and 'duplicate'.

The Vale linter checks documentation changes against the Elastic Docs style guide.

To use Vale locally or report issues, refer to Elastic style guide for Vale.

Copy link
Copy Markdown

@jeramysoucy jeramysoucy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues from Kibana security perspective. I see in the slack thread there was already discussion regarding the bypassing of expiration/long-lived vs. short lived credentials, but I suppose that is part of the trade off to solve the issue.

Copy link
Copy Markdown
Contributor

@slobodanadamovic slobodanadamovic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

tvernum and others added 2 commits March 9, 2026 15:04
Co-authored-by: shainaraskas <58563081+shainaraskas@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

>enhancement :Security/Authentication Logging in, Usernames/passwords, Realms (Native/LDAP/AD/SAML/PKI/etc) serverless-linked Added by automation, don't add manually Team:Security Meta label for security team v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to clone granted API keys

5 participants