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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,12 @@ kbagent feature user-show --project ALIAS --email EMAIL
kbagent feature user-add --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes]
kbagent feature user-remove --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes]

# token: scoped Storage tokens (Keboola single-bucket-write pattern; acting token needs canManageTokens; secret shown once).
# token: scoped Storage tokens (Keboola single-bucket-write pattern; secret shown once).
# create REQUIRES a MASTER (admin) token -- pre-flight MISSING_MASTER_TOKEN guard (exit 3)
# since #599: a non-master token with canManageTokens is CreateTokenVoter's "impossible state"
# and the API answers a generic 500. That defect is CREATE-ONLY: list/delete/refresh need only
# canManageTokens and are deliberately NOT guarded (RefreshTokenVoter also lets any token
# rotate itself), so rotating a leaked device token from an org-setup token keeps working.
kbagent token list --project NAME [--with-last-used] [--columns NAME ...]
kbagent token create --project NAME --description DESC [--bucket-write BUCKET ...] [--bucket-read BUCKET ...] [--component-access ID ...] [--can-read-all-file-uploads] [--expires-in N]
kbagent token delete --project NAME --token-id ID [--yes]
Expand Down
6 changes: 3 additions & 3 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ Seven methods for the "provision an OTLP ingest endpoint, then mint a narrowly-s

**Scoped Storage tokens** — mint, list, revoke, rotate:

- **`create_scoped_token(*, description, bucket_permissions=None, component_access=None, can_read_all_file_uploads=False, expires_in=None) -> ScopedTokenResult`** — `POST /v2/storage/tokens`. `bucket_permissions` is `{bucket_id: "read"|"write"}`; `expires_in` is seconds. The acting token must carry **`canManageTokens`** or the create 403s.
- **`create_scoped_token(*, description, bucket_permissions=None, component_access=None, can_read_all_file_uploads=False, expires_in=None) -> ScopedTokenResult`** — `POST /v2/storage/tokens`. `bucket_permissions` is `{bucket_id: "read"|"write"}`; `expires_in` is seconds. The acting token must be a **master (admin) token**: a non-master token carrying `canManageTokens` gets a generic 500 (`CreateTokenVoter` `LogicException`, issue #599), one without the flag gets a 403. The SDK facade has no pre-flight guard (only the CLI/service layer does), so you hit the raw API behavior.
- **`list_tokens() -> list[TokenListEntryResult]`** — `GET /v2/storage/tokens` (`0.86.0+`). Where the `token_id` for `delete_token` / `refresh_token` comes from. **Secrets are stripped before validation**: a project carrying the `force-decrypted-token` feature has the API embed live values in the listing, and `create_scoped_token` is meant to be the only reveal. The acting token needs `canManageTokens`.
- **`list_tokens(*, with_last_used=False)`** — `with_last_used` (`0.88.0+`) additionally derives each token's most recent activity from `GET /v2/storage/tokens/{id}/events`, populating `last_used` / `last_used_event` / `last_used_status` and returning the entries dormant-first. **Opt-in: one extra request per token**, run in parallel. Read `last_used_status` rather than a bare `last_used`: `used` (timestamp is real), `never` (**proven** unused — minted inside the ~6-month event-retention window with no activity), `unknown` (older than retention, so the API cannot say — do *not* treat as never), `error` (that token's lookup failed; the entry degrades, the call still returns). Activity inside a **development branch is invisible** to this feed, so a branch-only token reads as dormant.
- **`delete_token(token_id) -> None`** — `DELETE /v2/storage/tokens/{id}` (204, no body). Revokes.
- **`refresh_token(token_id) -> ScopedTokenResult`** — `POST .../tokens/{id}/refresh`. Rotates the secret in place; the returned `.token` is the new secret.
- **`refresh_token(token_id) -> ScopedTokenResult`** — `POST .../tokens/{id}/refresh`. Rotates the secret in place; the returned `.token` is the new secret. Any token may refresh itself; refreshing another token needs `canManageTokens`. No master token required — the `CreateTokenVoter` defect is create-only, so neither this facade nor the CLI guards it.

```python
tok = kbc.create_scoped_token(
Expand All @@ -202,7 +202,7 @@ device_secret = tok.token # ONE-TIME reveal — see the gotcha below
**The two-call enrollment example** — provision the endpoint, then mint the device's token scoped to exactly the sink bucket:

```python
with Client(url=URL, token=TOKEN) as kbc: # TOKEN must have canManageTokens
with Client(url=URL, token=TOKEN) as kbc: # TOKEN must be a master token for create
src = kbc.create_stream_source("my-source") # StreamSourceResult
# hand the device its ingest endpoint:
print(src.otlp_url) # carries the ingest secret in the path — UNMASKED
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ been retired, so its absence is NOT a promise (see §1 Rule 6).
| Edit SQL transformation blocks/codes | `kbagent transformation show` (FRESH ids) then `kbagent transformation edit --config-id K --change-description T --op '{"op":"set_code",...}'` -- 9 ops, ids `b{i}` / `b{i}.c{j}`, `--storage` REPLACES wholesale | `kbagent config update --configuration @body.json` (auto-normalizes `script[]`) | `transformation edit` without a fresh `show` (positional ids renumber); raw `PUT` (skips `script[]` normalization) |
| Run a job (and wait) | `kbagent job run --project P --component-id C --config-id K --wait` | -- | `job run` without `--wait` when the user expects the result |
| Provision / read an OTLP Data Streams endpoint | `kbagent stream create-source -p P --name N --type otlp [--if-not-exists]` (auto-creates logs/metrics/traces sinks) then `stream detail N -p P --reveal` | `stream list`; `--no-sinks` for a bare source | deriving the `stream-in` URL yourself (use `source.otlp.url`); printing the secret unasked |
| Mint / rotate / revoke a scoped Storage token | `kbagent token create -p P -d DESC [--bucket-write B ...] [--expires-in N]` / `token refresh` / `token delete --token-id ID` -- acting token needs `canManageTokens`, secret shown ONCE (persist `id` + `expires` only); `token list` (0.86.0+) is the only source of an existing `--token-id` | same ops on the SDK facade: `Client.create_scoped_token / refresh_token / delete_token` | `--component-access` / `--can-read-all-file-uploads` for an UPLOAD token (uploads need `--bucket-write` on the sink bucket; those flags gate READING others' uploads); claiming `stream create-source` needs a master token |
| Mint / rotate / revoke a scoped Storage token | `kbagent token create -p P -d DESC [--bucket-write B ...] [--expires-in N]` / `token refresh` / `token delete --token-id ID` -- `create` needs a MASTER token (0.89.0+ pre-flight `MISSING_MASTER_TOKEN`; `canManageTokens` alone = server 500, #599), refresh/list/delete need only `canManageTokens`; secret shown ONCE (persist `id` + `expires` only); `token list` (0.86.0+) is the only source of an existing `--token-id` | same ops on the SDK facade: `Client.create_scoped_token / refresh_token / delete_token` | `--component-access` / `--can-read-all-file-uploads` for an UPLOAD token (uploads need `--bucket-write` on the sink bucket; those flags gate READING others' uploads); claiming `stream create-source` needs a master token |
| Search items by name across projects | `kbagent search QUERY [--project P] [--type table\|bucket\|config\|flow\|data-app\|transformation] [--search-type textual\|config-based] [--regex]` -- `--regex` matches WHOLE terms on entity names: `report` does NOT match `monthly_report`, write `.*report.*` | -- | `--regex` with `--search-type config-based` (exit 2) |
| Search config JSON bodies | `kbagent search QUERY --search-type config-based [--project P]` (case-insensitive) | `kbagent config search --query Q` (config bodies only, no tables/buckets; case-SENSITIVE unless `-i`) | pulling every config with `config detail` to grep locally |
| Browse configs (exploration) | `kbagent config list` / `kbagent config search --query Q` | -- | a full-project pull just to grep locally |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,12 @@ Uses the per-project Storage token (no manage token). Control plane = `stream.<r
- `stream delete SOURCE_ID --project NAME [--branch ID] [--dry-run] [--yes|--force]` -- delete a source (destructive; async task polled to completion)

## Scoped Storage Tokens (since v0.66.0)
Mint, revoke, and rotate scoped Storage API tokens (the Keboola single-bucket-write pattern -- a device/component gets a token that can write to exactly one bucket). Uses the per-project Storage token from config (no manage token); the acting token must have the `canManageTokens` privilege. The token secret is displayed **once** on create/refresh and is never retrievable again. The importable SDK `Client(url, token)` mirrors this surface: `create_scoped_token` / `delete_token` / `refresh_token` (plus the stream-source primitives `create_stream_source` / `get_stream_source` / `list_stream_sources` / `delete_stream_source`) -- dicts on `.raw`, typed `ScopedTokenResult` / `StreamSourceResult` on the facade. See `sdk.md`.
Mint, revoke, and rotate scoped Storage API tokens (the Keboola single-bucket-write pattern -- a device/component gets a token that can write to exactly one bucket). Uses the per-project Storage token from config (no manage token). **`token create` requires a master (admin) Storage token** -- `canManageTokens` alone is not enough there (a non-master token carrying it makes the API answer a generic 500, issue #599), so it fails fast with `MISSING_MASTER_TOKEN` exit 3 on a pre-flight check before any write. That defect is create-only: `token list` / `token delete` / `token refresh` need just `canManageTokens` and are deliberately unguarded. The token secret is displayed **once** on create/refresh and is never retrievable again. The importable SDK `Client(url, token)` mirrors this surface: `create_scoped_token` / `delete_token` / `refresh_token` (plus the stream-source primitives `create_stream_source` / `get_stream_source` / `list_stream_sources` / `delete_stream_source`) -- dicts on `.raw`, typed `ScopedTokenResult` / `StreamSourceResult` on the facade. See `sdk.md`.
- `token list --project NAME` -- list the project's tokens (GET `/v2/storage/tokens`): id, description, created, expires (with an expired marker), master flag, and the token that created each one. This is how you find the `--token-id` that `delete` / `refresh` need. Secret values are stripped from every row before output, including under `--json` -- on a project carrying the `force-decrypted-token` feature the API embeds live secrets in the listing, and reproducing them would break the "revealed once, at mint" rule for every token at once. (since v0.86.0)
- `token list --project NAME --with-last-used` -- adds `lastUsed`, `lastUsedEvent` and `lastUsedStatus` per token and sorts dormant-first, turning the listing into a revocation worklist. **Opt-in: one extra API call per token** (parallel fan-out), so do not reach for it when you only need an id. Storage tokens carry no `lastUsed` of their own, so it is derived from `GET /v2/storage/tokens/{id}/events` narrowed server-side to `q=token.id:{id}`. `lastUsedStatus` is `used` | `never` | `unknown` | `error` -- see gotchas before acting on it, `never` and `unknown` are NOT interchangeable and dev-branch activity is invisible. `--columns` (repeatable) selects and orders the human table (`id`, `description`, `created`, `refreshed`, `expires`, `master`, `created_by`, `last_used`, `last_used_event`); it does not affect `--json`. (since v0.88.0)
- `token create --project NAME --description DESC [--bucket-write BUCKET ...] [--bucket-read BUCKET ...] [--component-access ID ...] [--can-read-all-file-uploads] [--expires-in N]` -- create a scoped token. `--bucket-write` / `--bucket-read` (both repeatable) grant per-bucket permissions; permissions are built read-first then write, so a bucket listed in both ends up writable. `--component-access` (repeatable) restricts the token to named components. `--expires-in N` sets a TTL in seconds. The secret is printed ONCE inside a Rich Panel.
- `token create --project NAME --description DESC [--bucket-write BUCKET ...] [--bucket-read BUCKET ...] [--component-access ID ...] [--can-read-all-file-uploads] [--expires-in N]` -- create a scoped token. `--bucket-write` / `--bucket-read` (both repeatable) grant per-bucket permissions; permissions are built read-first then write, so a bucket listed in both ends up writable. `--component-access` (repeatable) restricts the token to named components. `--expires-in N` sets a TTL in seconds. The secret is printed ONCE inside a Rich Panel. Requires a master token (pre-flight `MISSING_MASTER_TOKEN` otherwise, since v0.89.0).
- `token delete --project NAME --token-id ID [--yes]` -- revoke a token by its numeric id (DELETE `/v2/storage/tokens/{id}` -> 204). Destructive; confirms via prompt unless `--yes` or `--json`.
- `token refresh --project NAME --token-id ID [--yes]` -- rotate a token's secret (POST `.../refresh`); the new secret is printed ONCE. Confirms unless `--yes` or `--json`.
- `token refresh --project NAME --token-id ID [--yes]` -- rotate a token's secret (POST `.../refresh`); the new secret is printed ONCE. Confirms unless `--yes` or `--json`. Needs `canManageTokens`, NOT a master token (unlike `create`). The new secret is not written back to config.json -- rotating the alias's own token leaves it holding a dead value until `project edit --project ALIAS --token <NEW>`.

## Data Lineage
- `lineage build -d DIR -o FILE [--refresh] [--ai]` -- build column-level lineage graph from sync'd data
Expand Down
42 changes: 31 additions & 11 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,26 @@ Versioning convention:
--token-id ID` revokes. All three back the SDK facade
(`Client.create_scoped_token` / `refresh_token` / `delete_token`) and the same
service layer.
- **The acting token must carry `canManageTokens`.** Without it the create/refresh
`POST /v2/storage/tokens[...]` returns 403. A normal project-admin storage token
has it; a narrowly-scoped device token does NOT — you cannot bootstrap tokens
from a token you just minted unless you granted it `canManageTokens`.
- **`create` requires a MASTER (admin) token — `canManageTokens` alone is NOT
enough.** A non-master token carrying the flag is the Storage API's
"impossible state" (`CreateTokenVoter` `LogicException` → generic 500, issue
#599) — exactly the shape `org setup` / `project refresh` mint. Since v0.89.0
`token create` pre-flights `isMasterToken` and fails fast with
`MISSING_MASTER_TOKEN` (exit 3) naming the fix (`kbagent project edit
--project ALIAS --token <MASTER>`).
- **That defect is CREATE-ONLY — `refresh` / `list` / `delete` are NOT
master-guarded.** `RefreshTokenVoter` lets any token rotate **itself** and a
`canManageTokens` token rotate another, so rotating a leaked device token
from an `org setup` project token works and kbagent deliberately does not
block it (the incident path is the last place to add a credential upgrade
step). Do not "fix" a failing `refresh` by hunting for a master token — read
the actual error. A narrowly-scoped device token has neither privilege, so
you still cannot bootstrap tokens from a token you just minted.
- **`token refresh` does not write the new secret back to `config.json`.** The
value is printed once, exactly like `create`. Rotating the very token an
alias uses therefore leaves that alias holding a dead value — follow it with
`kbagent project edit --project ALIAS --token <NEW>` or the next command
fails on auth.
- **The secret is a ONE-TIME reveal.** `create` / `refresh` print the token value
once (human mode: inside a Rich panel; `--json`: the `token` field). It is never
retrievable again — persist only the `id` (to revoke/refresh later) and `expires`.
Expand Down Expand Up @@ -2972,7 +2988,8 @@ project list should do the same — never render the bare null.

The OAuth wizard URL embeds a short-lived **child** Storage API token scoped
to the target component. Minting this child token via `POST /v2/storage/tokens`
requires `canManageTokens` privilege, which only **master tokens** carry.
requires a **master (admin) token** — `canManageTokens` alone is not enough
(issue #599: a non-master token carrying it makes the API 500 instead of 403).

- Pre-flight: `kbagent` calls `verify_token` first and refuses with
`MISSING_MASTER_TOKEN` (exit 3) before any HTTP write happens. Without this
Expand All @@ -2983,10 +3000,12 @@ requires `canManageTokens` privilege, which only **master tokens** carry.
the OAuth flow via the Keboola UI instead.
- AI agents creating the project token via `kbagent project add` /
`kbagent project refresh` get a non-master token by default — they must
switch to a master token before calling `config oauth-url`. See
https://github.com/keboola/cli/issues/<TBD> for the upstream
request to make `project add` / `project refresh` mint a token with
`canManageTokens` so OAuth flows work out of the box.
switch to a master token before calling `config oauth-url`. Granting the
minted token `canManageTokens` would NOT help: issue #599 established the
Storage API rejects a non-admin token carrying that flag with a 500
(`CreateTokenVoter` "impossible state"), so a master token is the only
working credential here. `token create` carries the same guard since
v0.89.0 (`token refresh` does not — that endpoint has no such defect).

## `data-app logs` is the only unconstrained log surface (since v0.43.8)

Expand Down Expand Up @@ -4057,8 +4076,9 @@ the web UI.
`token refresh` are the only reveals, and a listing that dumped live values
would break that contract for every token in the project at once. Do not
reach for `kbagent http get` to work around this.
- **It needs `canManageTokens`, same as `create`.** A plain Storage token gets
a 403 -> `ACCESS_DENIED`.
- **It needs `canManageTokens` — but unlike `create` it does NOT need a master
token** (since v0.89.0 the mint is master-guarded, issue #599). A plain
Storage token gets a 403 -> `ACCESS_DENIED`.
- The master token appears in the listing with `isMasterToken: true` and cannot
be deleted -- the API refuses.
- SDK parity: `Client.list_tokens() -> list[TokenListEntryResult]`, secrets
Expand Down
19 changes: 19 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@
"Plugin docs: `CLAUDE.md`'s command list had never included `config delete` at all. "
"That silent drift made the command look nonexistent to AI agents reading it. "
"Added alongside the new commands, with the double-delete trap recorded in gotchas.md.",
"Fix (#599): `token create` on a non-master token now fails fast with "
"`MISSING_MASTER_TOKEN` (exit 3) instead of the API's generic 500 error. "
"Root cause found in #599: the API's `CreateTokenVoter` "
"treats a non-admin token carrying `canManageTokens` as an impossible state "
"and throws a `LogicException` -- and that is exactly the token shape "
"`org setup` / `project refresh` mint, so every project onboarded that way "
"hit the 500. The new pre-flight guard mirrors the existing `config oauth-url` "
"one (`isMasterToken` via `GET /v2/storage/tokens/verify`, checked before any "
"write) and its message names the fix: point the alias at a master token via "
"`kbagent project edit --project ALIAS --token <MASTER>`. The defect is "
"create-only, so `token refresh` / `token list` / `token delete` are "
"unchanged and deliberately NOT guarded: `RefreshTokenVoter` lets any token "
"rotate itself and a `canManageTokens` token rotate another, and guarding "
"that would break a working incident path -- rotating a leaked device token "
"from an `org setup` project token -- to fix nothing.",
"Change (#599): token-group docs no longer claim `canManageTokens` is enough "
"for `token create` -- the real requirement there is a master token. "
"`canManageTokens` alone is necessary but not sufficient for the mint; it is "
"sufficient for `token list` / `token delete` / `token refresh`.",
],
"0.88.0": [
"Fix (#624): column descriptions are now written where the Keboola UI and the "
Expand Down
Loading