|
| 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. |
0 commit comments