Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions administration/authentication/api_key_rotation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ Rotating API keys regularly limits the blast radius of a leaked credential. Kosl
When you rotate a service account API key, Kosli:

1. Generates a new API key and returns its value once.
2. Keeps the old key valid for a configurable grace period (default: **24 hours**).
3. Automatically revokes the old key when the grace period expires.
2. Sets the new key's expiry to the **rotated key's current expiry** unless you pass `--expires-at` (CLI) or `expires_at` (API), bounded by the server-side **maximum lifetime of 365 days from creation**.
3. Keeps the old key valid for a configurable grace period (default: **24 hours**).
4. Automatically revokes the old key when the grace period expires.

Choose a grace period that fits your deployment cadence — long enough to roll the new key out to every consumer, short enough to limit exposure.

<Warning>
Rotation on its own does not extend the credential. If the rotated key was already close to its expiry, the new key expires at the same moment unless you pass an explicit `--expires-at` (up to the 365-day cap).
</Warning>

## Where next

- [Rotating API keys (tutorial)](/tutorials/rotating_api_keys) — step-by-step walkthrough in the web app and via the API.
Expand Down
21 changes: 15 additions & 6 deletions terraform-reference/resources/service_account_api_key.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ resource "kosli_service_account" "ci" {
privilege = "member"
}

# A non-expiring API key
# API key with the server-side maximum lifetime (365 days from creation)
resource "kosli_service_account_api_key" "ci_key" {
service_account_name = kosli_service_account.ci.name
description = "Production CI key"
}

# An API key that expires (RFC3339 timestamp)
# API key with an explicit expiry (RFC3339 timestamp)
resource "kosli_service_account_api_key" "ci_key_expiring" {
service_account_name = kosli_service_account.ci.name
description = "Temporary CI key"
expires_at = "2100-01-01T00:00:00Z"
expires_at = "2026-12-31T00:00:00Z"
}

# The raw key is only available on creation and is sensitive
Expand All @@ -54,12 +54,21 @@ output "ci_api_key" {

## Expiry

The `expires_at` attribute is an RFC3339 timestamp, e.g. `2100-01-01T00:00:00Z` (offsets such as `+01:00` are accepted and normalized to UTC). Omit it for a key that never expires. The timestamp must not be in the past.
Every API key expires. The server caps each key's lifetime at **365 days from creation** and enforces the cap regardless of the client.

The `expires_at` attribute is an RFC3339 timestamp, e.g. `2026-12-31T00:00:00Z` (offsets such as `+01:00` are accepted and normalized to UTC). Behavior:

- **Omitted** → the server applies the maximum 365-day expiry.
- **Set within 365 days** → honored as-is.
- **Set beyond 365 days** → silently shortened to the 365-day cap. Terraform then reports the result as an inconsistent result after apply and marks the resource tainted so the next apply revokes and recreates it. Choose an `expires_at` within the 365-day window to avoid this.
- **In the past** → rejected server-side at apply time.

Keys that never expire can no longer be created.
Comment on lines +61 to +66

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 "Omitted → the server applies the maximum 365-day expiry" bullet is true only at creation, and it now reads as contradicting the schema entry at line 96 ("Removing a previously set value from configuration leaves the existing expiry unchanged").

The old text closed that gap with the trailing clause you removed (to get a non-expiring key again, the key must be recreated (e.g. via terraform taint ...)). Without it, a reader who deletes expires_at from a live resource to "reset the clock to 365 days" gets no change at all, and the page doesn't say how to actually get a fresh key.

Suggested change
- **Omitted** → the server applies the maximum 365-day expiry.
- **Set within 365 days** → honored as-is.
- **Set beyond 365 days** → silently shortened to the 365-day cap. Terraform then reports the result as an inconsistent result after apply and marks the resource tainted so the next apply revokes and recreates it. Choose an `expires_at` within the 365-day window to avoid this.
- **In the past** → rejected server-side at apply time.
Keys that never expire can no longer be created.
- **Omitted at creation** → the server applies the maximum 365-day expiry.
- **Set within 365 days** → honored as-is.
- **Set beyond 365 days** → silently shortened to the 365-day cap. Terraform then reports `Provider produced inconsistent result after apply`, and the key you get does not have the expiry you asked for. Choose an `expires_at` within the 365-day window to avoid this.
- **In the past** → rejected server-side at apply time.
Keys that never expire can no longer be created. Removing `expires_at` from an existing resource does not extend the key — the expiry set at creation stands. To mint a replacement with a fresh 365 days, force a new key with `terraform apply -replace=kosli_service_account_api_key.ci_key`.

Two things folded into the suggestion, take or leave independently:

  • marks the resource tainted so the next apply revokes and recreates it is a specific Terraform-mechanics claim I can't verify from this repo. Provider produced inconsistent result after apply is an error diagnostic; whether the object ends up tainted and auto-recreated depends on the operation and Terraform version, and terraform taint has been deprecated in favour of -replace= since 0.15.2. Either confirm it against a real apply of internal/provider/resource_service_account_api_key.go or state only the part that's certain.
  • -replace= as the documented remedy, since the page no longer mentions any way to get a fresh key.


To derive dates dynamically, use Terraform's built-in functions, e.g. `timeadd("2026-01-01T00:00:00Z", "8760h")`.

<Note>
All timestamps (`expires_at`, `created_at`, `last_used_at`) are RFC3339 UTC strings. `last_used_at` is null for a key that has never been used; `expires_at` is null for a key that never expires.
All timestamps (`expires_at`, `created_at`, `last_used_at`) are RFC3339 UTC strings. `last_used_at` is null for a key that has never been used.
</Note>

## Import
Expand All @@ -84,7 +93,7 @@ Because the raw key value is only returned at creation time, the `key` attribute

### Optional

- `expires_at` (String) RFC3339 timestamp at which the key expires, e.g. `2100-01-01T00:00:00Z` (offsets allowed; whole seconds only). Omit for a key that never expires. Must not be in the past (validated server-side at apply time). Changing this forces creation of a new key. Removing a previously set value from configuration leaves the existing expiry unchanged; to get a non-expiring key again, the key must be recreated (e.g. via `terraform taint` or by changing another argument).
- `expires_at` (String) RFC3339 timestamp at which the key expires, e.g. `2026-12-31T00:00:00Z` (offsets allowed; whole seconds only). Must not be in the past (validated server-side at apply time) and must be no more than 365 days out: the server caps every key's lifetime and silently shortens a longer expiry, which Terraform then reports as an inconsistent result after apply. Omit to let the server apply the maximum 365-day expiry; keys that never expire can no longer be created. Changing this forces creation of a new key. Removing a previously set value from configuration leaves the existing expiry unchanged.

### Read-only

Expand Down
16 changes: 13 additions & 3 deletions tutorials/rotating_api_keys.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@ Kosli never stores your API token in plain text. Only a cryptographic hash of th
When you rotate a service account API key, Kosli:

1. Generates a **new** API key immediately and returns its value **once**.
2. Keeps the **old** key valid for a configurable grace period (default: **24 hours**).
3. Automatically revokes the old key when the grace period expires.
2. Sets the new key's expiry to the **rotated key's current expiry** unless you pass `--expires-at` (CLI) or `expires_at` (API), bounded by the server-side **maximum lifetime of 365 days from creation**.
3. Keeps the **old** key valid for a configurable grace period (default: **24 hours**).
4. Automatically revokes the old key when the grace period expires.

The grace period lets you roll the new key out to all consumers without an interruption in service. Choose a window that matches your deployment cadence — short enough to limit exposure, long enough to update every dependent system.

<Warning>
Rotation on its own does not extend the credential. If the rotated key is already close to its expiry (for example, most of the way through the 365-day cap), the new key inherits that expiry and dies at the same moment — the exact failure rotation is supposed to prevent. Pass `--expires-at` to reset the clock, up to the 365-day cap.
</Warning>
Comment on lines +29 to +31

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 Warning's only remedy is --expires-at, but the Web UI tab (lines 38–45) is the first tab and offers only a grace period — no expiry field. So the reader most likely to hit this failure mode is the one given no way to fix it.

administration/authentication/service_accounts.md:24 shows the create dialog does have a TTL field. If Regenerate has one too, document it in step 5 of the Web UI tab and reference it here. If it doesn't, say so explicitly, e.g.:

…Pass --expires-at (CLI) or expires_at (API) to reset the clock, up to the 365-day cap. Rotating in the web app always inherits the old key's expiry — use the CLI or API when you need to extend it.

Same applies to the Warning at administration/authentication/api_key_rotation.md:20-22, which names only the CLI flag.


## Rotate a key

Choose the interface that best fits your workflow. All three trigger the same rotation flow described above.
Expand All @@ -46,10 +51,13 @@ Choose the interface that best fits your workflow. All three trigger the same ro
kosli rotate api-key <<key-id>> \
--service-account <<service-account-name>> \
--grace-period-hours 24 \
--expires-at 2026-12-31 \

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.

2026-12-31 is 111 days out from today, and the docs state past values are rejected server-side. From 2027-01-01 this becomes an example that errors when copied. The old 2100-01-01 violated the cap but never went stale; swapping in a fixed near-term date trades one wrong example for one with an expiry date of its own.

This page already uses the <<...>> placeholder convention for <<key-id>> and <<service-account-name>>, so:

Suggested change
--expires-at 2026-12-31 \
--expires-at <<YYYY-MM-DD>> \

Same issue at line 89 — 1798761600 is 2027-01-01 — and in terraform-reference/resources/service_account_api_key.mdx at lines 45, 59 and 96 (2026-12-31T00:00:00Z), plus line 68's timeadd("2026-01-01T00:00:00Z", "8760h"), which resolves to 2027-01-01 and so goes stale on the same date despite looking dynamic.

--api-token "$KOSLI_ADMIN_TOKEN" \
--org "$ORG"
```

`--expires-at` accepts an epoch timestamp, `YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS`, or an RFC3339 timestamp, and is capped at 365 days from creation. Omit it to inherit the rotated key's expiry — combine that with a rotation cadence well inside the 365-day cap, or pass `--expires-at` to reset the clock.

Rotate multiple keys for the same service account in one call by passing additional key IDs. When `--grace-period-hours` is omitted, the server-side default grace period applies:

```shell
Expand Down Expand Up @@ -78,10 +86,12 @@ Choose the interface that best fits your workflow. All three trigger the same ro
curl -X POST \
-H "Authorization: Bearer <<your-admin-api-key>>" \
-H "Content-Type: application/json" \
-d '{"grace_period_hours": 24}' \
-d '{"grace_period_hours": 24, "expires_at": 1798761600}' \
https://app.kosli.com/api/v2/service-accounts/<<your-org>>/<<service-account-name>>/api-keys/<<key-id>>/rotate
```

`expires_at` is an epoch timestamp for the new key's expiry. Omit it to inherit the rotated key's expiry. The value is capped at 365 days from creation.

The response contains the new API key value. Capture it directly into your secrets store:

```shell
Expand Down
Loading