Skip to content

Commit 91aa9cf

Browse files
committed
Define safe API key state boundaries
1 parent eac44ec commit 91aa9cf

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

docs/api-key-state.md

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# API Key Terraform State Design
2+
3+
## Decision
4+
5+
The masked `kernel_api_key` data source is safe to implement after a tagged
6+
Kernel Go SDK exposes the API's exact-name list filter.
7+
8+
The `kernel_api_key` resource is deferred. Its Terraform state shape is
9+
accepted below, but Create and Rotate must not ship until the API provides
10+
replayable idempotency and lets the provider identify or reject rotation of the
11+
credential authenticating the current request. The API must also expose that
12+
credential's effective project scope so provider-default fallback can settle
13+
predictably into Terraform state.
14+
15+
## First Principles
16+
17+
An API key is durable infrastructure, but its plaintext is not durable API
18+
metadata. Kernel returns plaintext exactly once after Create or Rotate. Later
19+
reads return a masked key and stable metadata.
20+
21+
Terraform can manage that lifecycle only when:
22+
23+
- retrying an uncertain Create or Rotate returns the same key and plaintext;
24+
- refresh never replaces plaintext state with a masked value;
25+
- import does not pretend it can recover plaintext;
26+
- rotation is an explicit configuration change;
27+
- the provider cannot rotate away the credential it is using unknowingly; and
28+
- project scope follows the provider's documented precedence and settles into
29+
durable state.
30+
31+
## Current Kernel Contract
32+
33+
Kernel Go SDK v0.76.0, the latest tagged release at the time of this decision,
34+
exposes Create, Get, Update, List, Delete, and Rotate. The durable API contract
35+
has these properties:
36+
37+
- Create returns a new canonical ID, plaintext key, masked key, name, creator,
38+
timestamps, and optional project metadata.
39+
- Get and List return masked metadata only.
40+
- Update changes only the name.
41+
- Delete soft-deletes a key and rejects deletion of the current key with the
42+
coded `cannot_delete_current_key` error.
43+
- Rotate creates a new canonical key, returns its plaintext once, and schedules
44+
the old key to expire at the earlier of its existing expiration and the grace
45+
deadline. Rotation never extends an old key's life.
46+
- Rotation does not soft-delete the old key. Its expired record remains in
47+
ordinary active listings because that status currently means non-deleted.
48+
- Names are not unique.
49+
- Project-scoped callers can manage keys only in their own project and cannot
50+
create or access organization-wide keys.
51+
- A deleted key is hidden from ordinary Get and a repeated Delete returns
52+
`not_found`.
53+
54+
The API supports an exact-name List filter. It is generated in the pending Go
55+
SDK v0.77.0 release, so the provider must wait for that tag rather than adding
56+
direct HTTP or a second client.
57+
58+
## Future Resource State
59+
60+
The resource should use this state model once the API blockers are resolved:
61+
62+
| Attribute | Terraform behavior |
63+
| --- | --- |
64+
| `id` | Computed canonical API-key ID. Rotate replaces it with the new key's ID. |
65+
| `name` | Required durable name. Updated in place. Names are labels, not identity. |
66+
| `project_id` | Optional scope override. It defaults to the provider-level `project_id`, then the authenticated key's project binding. Null after resolution creates an organization-wide key. Changing resolved scope replaces the key. |
67+
| `days_to_expire` | Optional lifetime from 1 through 3650 days on Create. When omitted on a new key, the key does not expire. A later finite change is valid only with a simultaneous `rotation_keeper` change; because v1 uses the seven-day default grace, the changed lifetime must be 8 through 3650 days. Removing a known finite value is rejected because Rotate cannot produce a never-expiring replacement. After metadata-only import, omission means the original relative lifetime is unmanaged. |
68+
| `rotation_keeper` | Optional opaque string. Only a change after Create triggers Rotate. Its value is provider state, not remote metadata. |
69+
| `key` | Computed, sensitive plaintext returned by Create or Rotate. Read preserves the prior value; import leaves it null. |
70+
| `masked_key` | Computed durable masked value from Get. It never substitutes for `key`. |
71+
| `created_at` | Computed creation timestamp. |
72+
| `expires_at` | Computed nullable expiration timestamp. |
73+
| `created_by` | Computed creator metadata. |
74+
| `project_name` | Computed nullable project metadata. |
75+
76+
`key` being sensitive controls display, not storage encryption. The plaintext
77+
remains in Terraform state after Create or Rotate so downstream configuration
78+
can use it. Users must use an encrypted remote backend with restricted state
79+
access. Removing the value on the next Read would make dependent configuration
80+
unstable and would not erase it from state history.
81+
82+
`days_to_expire` records create-time intent; Kernel reads expose the resulting
83+
absolute `expires_at`, not the original relative input. Import therefore cannot
84+
reconstruct that input and must not guess it from timestamp subtraction.
85+
86+
Project resolution is resource override, provider default, authenticated-key
87+
binding, then organization-wide when no project applies. State stores the
88+
resolved project ID. The current API requires a project-scoped caller to send
89+
`project_id` but does not expose that caller's canonical scope to this provider.
90+
Until current-key metadata closes that gap, callers must configure either the
91+
resource or provider project explicitly.
92+
93+
## Lifecycle
94+
95+
Create sends explicit `name`, resolved `project_id`, and `days_to_expire`
96+
values. It disables automatic mutation retries. On success it stores the
97+
canonical ID and plaintext before any later operation can fail.
98+
99+
Read uses masked Get metadata. It refreshes readable fields while preserving
100+
the prior plaintext, create-time lifetime, and rotation keeper. A coded
101+
`not_found` response removes the resource from state. Expiration alone does not
102+
remove the durable key record.
103+
104+
Update patches a changed name before Rotate so the replacement key copies the
105+
new name. A changed `rotation_keeper` then calls Rotate and stores the returned
106+
new ID and plaintext immediately. If `days_to_expire` changes in the same plan,
107+
Rotate uses that new lifetime; changing the lifetime without changing the
108+
keeper is a plan error. The old key is no longer owned by the Terraform
109+
resource. Kernel expires it by the grace deadline, sooner when its existing
110+
expiration is earlier, and immediately when it is already expired. Kernel
111+
retains the non-deleted historical record; deleting the resource later deletes
112+
only the replacement key. Rotation uses the API's default grace and otherwise
113+
preserves the old key's original lifetime for the replacement. Custom grace
114+
controls are deferred until a concrete Terraform workflow requires them.
115+
116+
## Null, Empty, And Defaults
117+
118+
- `name` must contain 1 through 255 UTF-8 bytes and must equal its trimmed value.
119+
Empty, whitespace-only, and leading/trailing-whitespace values are invalid so
120+
API normalization cannot create a perpetual diff.
121+
- `project_id` may be omitted for scope resolution, but an explicit empty string
122+
is invalid. A resolved null value means organization-wide.
123+
- `days_to_expire` omitted or null means no expiry for a newly created key. On
124+
Rotate, omission asks Kernel to preserve the old key's original lifetime. A
125+
changed rotation lifetime must be at least eight days so the replacement
126+
outlives the seven-day default grace window; this does not restrict a new
127+
key's initial 1-3650-day lifetime. Removing a known finite value is a plan
128+
error because the API treats rotation null as preserve, not never-expiring.
129+
Moving to no expiry requires creating a separate key and migrating consumers
130+
before removing the old resource.
131+
- `rotation_keeper` may be omitted. When configured it must be non-empty. Create
132+
records its initial value without an extra Rotate because Create already
133+
issued fresh plaintext. Import leaves it null; setting it after import is an
134+
explicit first rotation, and every later value change rotates again.
135+
- The API's omitted rotation grace defaults to seven days. The v1 resource does
136+
not expose a second grace control.
137+
- Nullable read fields remain Terraform null. Zero times and empty strings must
138+
not be used as substitutes for API null values.
139+
140+
Delete treats `not_found` as success. The coded self-delete response is a
141+
diagnostic and retains the resource ID so a different provider credential can
142+
retry. Terraform must not hide that failure or remove state.
143+
144+
Import accepts only the canonical API-key ID. Read then imports masked metadata.
145+
The plaintext `key`, original `days_to_expire`, and prior rotation keeper remain
146+
unknown or unset. Import documentation must call this metadata-only import.
147+
148+
## Self-Use Rule
149+
150+
A provider instance must be authenticated by a separate administrative key
151+
from every `kernel_api_key` resource it manages. Provider configuration cannot
152+
depend safely on a key created by that same provider instance.
153+
154+
The API already prevents self-delete, but Rotate currently allows the current
155+
key to rotate and schedules it to expire. The provider cannot derive the
156+
authenticated key's canonical ID from its plaintext configuration or masked
157+
metadata. Documentation alone is not a sufficient guard for an operation that
158+
can make the next Terraform run unable to authenticate.
159+
160+
Before the resource ships, Kernel must expose request-context metadata that
161+
identifies the current key and its canonical project scope, or reject
162+
self-rotation with a stable coded error and separately expose the effective
163+
project binding. The provider will use that signal as an internal safety check;
164+
current-key identity does not belong in durable Terraform state.
165+
166+
## Idempotency Gate
167+
168+
Create and Rotate have no idempotency key. A connection failure after the API
169+
commits but before Terraform receives the response loses the only plaintext
170+
response. Create can leave an untracked credential. Rotate can additionally
171+
schedule the old credential to expire, and a retry can create another key.
172+
173+
Before the resource ships, both operations must accept an idempotency key and
174+
replay the same canonical ID and plaintext response for the same request. It is
175+
not enough to deduplicate by name because names are intentionally non-unique.
176+
The implementation design must prove that its request token survives a failed
177+
apply and process restart; API support without a stable Terraform-side token is
178+
not sufficient. Generic SDK mutation retries remain disabled outside that
179+
replay contract.
180+
181+
## Masked Data Source
182+
183+
The data source is independent of plaintext lifecycle and may ship first. It
184+
should accept exactly one of canonical `id` or exact `name`, scan all pages for
185+
name lookup, deduplicate by ID, and diagnose zero or multiple non-deleted
186+
matches. Expired-but-not-deleted keys remain visible because they are durable
187+
records under the current API status definition. The API's name filter follows
188+
the production database's case- and accent-insensitive collation; the provider
189+
must post-filter returned names byte-for-byte so Terraform's exact selector has
190+
stable semantics. Rotation retains an old row with the same name, so name
191+
lookup becomes ambiguous after rotation and callers must use the canonical ID.
192+
It may expose ID, name, masked key, creator, creation/expiration timestamps, and
193+
nullable project metadata. It must never expose plaintext, deleted audit rows,
194+
or provider-authentication identity.
195+
196+
## Unblocking Checklist
197+
198+
- Tag an SDK release containing the exact API-key name filter.
199+
- Add replayable idempotency for API-key Create and Rotate.
200+
- Add a stable current-key/project-scope signal or coded self-rotation
201+
rejection plus effective-scope metadata.
202+
- Verify nullable SDK response fields against API `null` responses.
203+
- Acceptance-test ASCII and multibyte name boundaries against the API's
204+
byte-count validation.
205+
- Acceptance-test a short initial lifetime, unchanged short-lifetime rotation,
206+
rejection of a changed 1-7-day rotation lifetime, and rejection of a known
207+
finite-to-null transition.
208+
- Acceptance-test Create, Read, rename, explicit rotation, metadata-only
209+
import, first rotation after import, project scope, retained rotation history,
210+
near-expiry and already-expired rotation, not-found deletion, and self-use
211+
diagnostics.
212+
- Repeat the sensitive-state and release review before registering the resource.

docs/architecture.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ Late or conditional v1 work:
7373

7474
Blocked candidates must remain unimplemented until the API and a tagged SDK expose the required durable contract. Provider code must not guess missing semantics, patch generated SDK code, or add a fallback HTTP client to bypass the durable client module.
7575

76+
The accepted plaintext, import, rotation, and self-use model for API keys is
77+
defined in [API Key Terraform State Design](api-key-state.md). The masked data
78+
source may proceed after the exact-name SDK surface is tagged. The resource
79+
remains blocked on replayable Create/Rotate idempotency and a current-key
80+
rotation guard with effective authenticated project-scope metadata.
81+
7682
`kernel_deployment` remains core v1 scope but is currently blocked until a
7783
tagged SDK exposes source provenance, the API supports deterministic durable
7884
readback, and the write-only environment/token plus metadata-only import design

0 commit comments

Comments
 (0)