diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 288e0ee4..aad85f5c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.80.0", + "version": "0.81.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 174684b7..2134da43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,7 +287,7 @@ plugins/kbagent/ ``` # Global options: --json, --verbose, --no-color, --config-dir, --deny-writes, --deny-destructive, --allow-env-manage-token -# Headless / token-only (0.50.0+): export KBAGENT_PROJECT_FROM_ENV=1 + KBC_TOKEN + KBC_STORAGE_API_URL to synthesize an in-memory `__env__` project (no `project add`, no config.json on disk; token never persisted). Use `--project __env__`. Same env setup also powers `kbagent serve`. +# Headless / token-only (0.50.0+): export KBAGENT_PROJECT_FROM_ENV=1 + KBC_TOKEN + KBC_STORAGE_API_URL to synthesize an in-memory `__env__` project (no `project add`, no config.json on disk; token never persisted). Use `--project __env__`. Same env setup also powers `kbagent serve`. KBC_TOKEN accepts either a real Storage token or a `kbagent auth pat-create`-minted PAT (0.81.0+, see `auth pat-*` below) -- both work identically in this path. kbagent auth login [--stack URL|alias] [--device-code] [--register-projects] kbagent auth status [--stack URL|alias] @@ -333,6 +333,23 @@ kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] # instead of silently skipping the second project). See docs/programmatic-auth-login-plan.md # section 4.5 for the full design. +kbagent auth pat-create --name NAME [--stack URL|alias] [--totp-code CODE] [--read-only] [--ttl-days N] +kbagent auth pat-revoke PAT_ID [--stack URL|alias] [--yes] +# `auth pat-*` (0.81.0+): mints/revokes a Personal Access Token (`kbc_pat_...`) for one-time CI/CD +# setup -- the sanctioned alternative to a raw Storage token for pipelines, and the only session-auth +# surface meant to run non-interactively downstream of a one-time human step. `pat-create` spends an +# EXISTING `auth login` session's access token (it does not log in itself) to do a TOTP step-up +# (`POST /v1/auth/sudo`) followed by `POST /v1/auth/pat`; --totp-code is prompted interactively when +# omitted, and is REQUIRED (fails fast) under --json or a non-TTY stdin -- there is no unattended path +# to mint a PAT, matching `auth login`'s own "needs a human" boundary. The token is printed exactly +# once and never stored by kbagent. Store it as `KBC_TOKEN` (the `KBAGENT_PROJECT_FROM_ENV=1` headless +# path) or via `project add --token`: `make_client_factory` (services/base.py) recognizes the +# `kbc_pat_` prefix on a plain static token and sends it as `Authorization: Bearer` instead of +# `X-StorageApi-Token` -- those are different auth schemes on the Storage API, not different encodings +# of one, so a PAT dropped into the old header would just fail. A PAT does not rotate (unlike a +# session); replace it (`pat-create` again) instead of expecting a refresh. `pat-revoke` needs no +# step-up. New error codes: AUTH_SUDO_REQUIRED, AUTH_MFA_INVALID. + kbagent project add --project NAME --url URL --token TOKEN kbagent project list kbagent project remove --project NAME diff --git a/docs/error-codes.md b/docs/error-codes.md index c2483cdc..dd82f608 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -182,3 +182,5 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. | `AUTH_STATE_MISMATCH` | The PKCE callback's `state` parameter did not match the one generated at login start | | `SESSION_EXPIRED` | The programmatic-auth session's refresh token expired or was revoked; run `kbagent auth login` again | | `SESSION_NOT_FOUND` | No programmatic-auth session is persisted for this stack; run `kbagent auth login` | +| `AUTH_SUDO_REQUIRED` | `auth pat-create` could not activate the sudo (step-up) window -- check the TOTP code and try again | +| `AUTH_MFA_INVALID` | An MFA verification code (TOTP or recovery code) was rejected as invalid or expired | diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 7f9a7a9f..dcfc0ac2 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.80.0", + "version": "0.81.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 353039b4..1215a805 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -72,6 +72,8 @@ When working inside a git repository or project directory, run `kbagent init` (o | Show the programmatic-auth session health for a stack | `kbagent auth status` | | Revoke and clear the local programmatic-auth session for a stack | `kbagent auth logout` | | Register accessible projects from the current session as local aliases | `kbagent auth register-projects` | +| Mint a Personal Access Token from the current session, for one-time CI/CD setup | `kbagent auth pat-create --name NAME` | +| Revoke a Personal Access Token. | `kbagent auth pat-revoke ` | | Add a new Keboola project connection | `kbagent project add --project ALIAS` | | List all connected Keboola projects | `kbagent project list` | | Remove a Keboola project connection | `kbagent project remove --project ALIAS` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index cf0962c2..9f50a83f 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -25,6 +25,9 @@ unattended agent task.** Issues a USER-scoped "programmatic session" - `auth logout [--stack URL|alias] [--remove-projects] [--yes]` -- revoke the refresh token server-side and delete the local session from `auth.json`. `--remove-projects` also removes `config.json` aliases pointing at this session (sentinel-token projects only; a static-token project on the same stack is never touched). - `auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] [--alias ID=ALIAS ...] [--yes]` -- register an EXISTING session's accessible projects as `config.json` aliases, without re-running `login`. Fixes two usability gaps in plain `login`: nothing was registered unless `--register-projects` was passed, and the suggested alias was always slugified from the project NAME, so a project id like `9840` from the login table never resolved as `--project 9840`. `--all` selects every accessible project; `--project-id ID` (repeatable) selects specific ones (an inaccessible id raises a `ConfigError`); passing neither starts an interactive arrow-key + spacebar checkbox picker -- every not-yet-registered project preselected, up/down or `j`/`k` move, `space` toggles, `a` selects/deselects all, `enter` accepts, `q`/`esc`/`ctrl-c` cancels -- followed by a single `Edit aliases?` confirm (default no) that opens the old per-project alias prompt only if you opt in (each row already shows its suggested alias), then a final `typer.confirm`. On a piped stdin or a terminal without real interactive capabilities, the picker falls back to the original typed prompt (numbers / ranges `1-3` / `all` / `none`). In a non-TTY or `--json` context with neither `--all` nor `--project-id`, the command fails fast telling the caller to pass `--all` or `--project-id` instead of hanging on a prompt. `--alias ID=ALIAS` (repeatable) overrides the suggested alias for a given project id in every mode, including as the picker's prefilled default. `--yes` skips only the picker's final confirmation. Two collision rules, in both modes: a project already registered under an alias for this project+stack reports `status: "exists"` (no-op -- rename via `project edit --new-alias` instead of re-registering); an alias already claimed by a different project (or a static-token project) reports `status: "skipped"` with a rename-hint note -- an existing `config.json` entry is never overwritten. `auth login` (without `--register-projects`) now also offers this same picker interactively right after a successful login, when stdout is a TTY and `--json` was not used; otherwise it just prints the hint to run this command later, and a failure in that optional follow-up never changes `login`'s own (already-successful) exit code. +- `auth pat-create --name NAME [--stack URL|alias] [--totp-code CODE] [--read-only] [--ttl-days N]` -- mint a Personal Access Token (`kbc_pat_...`) from an EXISTING `auth login` session (does not log in itself). Does a TOTP step-up (`POST /v1/auth/sudo`) then mints the token (`POST /v1/auth/pat`); `--totp-code` is prompted interactively when omitted and is REQUIRED under `--json` or a non-TTY stdin -- no unattended path to mint a PAT, same boundary as `auth login`. Prints the token exactly once (`access_token`); kbagent never stores it. Intended use: one-time CI/CD setup -- store the result as `KBC_TOKEN` (`KBAGENT_PROJECT_FROM_ENV=1`) or via `project add --token`. `make_client_factory` (`services/base.py`) recognizes the `kbc_pat_` prefix on a plain static token and routes it to `Authorization: Bearer` instead of `X-StorageApi-Token` (distinct auth schemes on the Storage API), so a PAT works everywhere a session project already does (`sync`, `storage`, `config`, ...) -- it just doesn't rotate, so replace it (`pat-create` again) instead of expecting a refresh. New error codes: `AUTH_SUDO_REQUIRED`, `AUTH_MFA_INVALID`. +- `auth pat-revoke PAT_ID [--stack URL|alias] [--yes]` -- revoke a Personal Access Token. No sudo step-up needed. Idempotent: revoking an already-revoked id is not an error. + v1 scope: the Storage + Manage paths. `serve` reaches them too (it delegates to the same already-guarded services), so a session project works over the REST API and web UI -- but whoever holds `KBAGENT_SERVE_TOKEN` then acts as the signed-in diff --git a/pyproject.toml b/pyproject.toml index 9f0a5078..c10487ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.80.0" +version = "0.81.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/auth/auth_client.py b/src/keboola_agent_cli/auth/auth_client.py index e3afacba..ec618dc5 100644 --- a/src/keboola_agent_cli/auth/auth_client.py +++ b/src/keboola_agent_cli/auth/auth_client.py @@ -33,6 +33,7 @@ AUTH_CLIENT_ID, AUTH_DEVICE_PATH, AUTH_DEVICE_TOKEN_PATH, + AUTH_PAT_PATH, AUTH_PKCE_AUTHORIZE_PATH, AUTH_PKCE_TOKEN_PATH, AUTH_REFRESH_CONTENTION_DEFAULT_DELAY, @@ -41,6 +42,7 @@ AUTH_REFRESH_CONTENTION_STRING_CODE, AUTH_REFRESH_TIMEOUT, AUTH_SESSIONS_PATH, + AUTH_SUDO_PATH, AUTH_TOKEN_INTROSPECT_PATH, AUTH_TOKEN_REFRESH_PATH, AUTH_TOKEN_REVOKE_PATH, @@ -54,7 +56,9 @@ DevicePollResult, DevicePollStatus, IntrospectResponse, + PatCreateResult, RevokeResult, + SudoResult, ) logger = logging.getLogger(__name__) @@ -647,6 +651,87 @@ def delete_session(self, session_id: str, access_token: str) -> RevokeResult: message=self._truncate(self._extract_error_message(response)), ) + # ------------------------------------------------------------------ + # Sudo step-up + Personal Access Tokens (since 0.81.0) + # ------------------------------------------------------------------ + + def sudo_totp(self, access_token: str, totp_code: str) -> SudoResult: + """Activate the sudo window on the current session via TOTP (``POST /v1/auth/sudo``). + + Required before `create_pat` -- a PAT cannot be minted outside an + active sudo window. Only the TOTP factor is wired here: WebAuthn + step-up needs a live browser ceremony this CLI has nowhere to host, + and password step-up is rejected outright once MFA is configured + (the API's own rule, not a restriction added here). + """ + response = self._do_request( + "POST", + AUTH_SUDO_PATH, + json={"type": "totp", "totpCode": totp_code}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + data = response.json() + return SudoResult( + verified=bool(data.get("sudoVerified")), + expires_at=str(data.get("sudoExpiresAt", "")), + timeout_seconds=int(data.get("sudoTimeoutSeconds", 0)), + ) + + def create_pat( + self, + access_token: str, + *, + name: str, + read_only: bool = False, + expires_in: int | None = None, + ) -> PatCreateResult: + """Mint a Personal Access Token (``POST /v1/auth/pat``). + + Requires an active sudo window (`sudo_totp`) on the same session; + the server answers 403 otherwise. The returned `PatCreateResult` + carries the bearer value exactly once -- the caller must print it + and never persist it. + """ + body: dict[str, Any] = {"name": name} + if expires_in is not None: + body["expiresIn"] = expires_in + if read_only: + body["scope"] = {"all": True, "readOnly": True} + response = self._do_request( + "POST", + AUTH_PAT_PATH, + json=body, + headers={"Authorization": f"Bearer {access_token}"}, + ) + return PatCreateResult.model_validate(response.json()) + + def revoke_pat(self, access_token: str, pat_id: str) -> RevokeResult: + """Revoke a Personal Access Token (``DELETE /v1/auth/pat/{id}``). + + Idempotent server-side (a second call against an already-revoked id + still returns 204) and, like `revoke`/`delete_session`, never raises + -- a failed revoke must be reported distinctly, not thrown, so a + caller can still tell the operator exactly what to check. + """ + try: + response = self._client.request( + "DELETE", + f"{AUTH_PAT_PATH}/{pat_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + except httpx.HTTPError as exc: + return RevokeResult( + confirmed=False, + message=self._truncate(f"{type(exc).__name__}: {exc}"), + ) + + if response.status_code < 300 or response.status_code == 404: + return RevokeResult(confirmed=True) + return RevokeResult( + confirmed=False, + message=self._truncate(self._extract_error_message(response)), + ) + @staticmethod def _extract_error_message(response: httpx.Response) -> str: """Best-effort human message from a failed response body. diff --git a/src/keboola_agent_cli/auth/models.py b/src/keboola_agent_cli/auth/models.py index 935bdaf9..5c58fae6 100644 --- a/src/keboola_agent_cli/auth/models.py +++ b/src/keboola_agent_cli/auth/models.py @@ -3,8 +3,9 @@ Two families of model live here: - Wire models (`AuthUser`, `CliTokenResponse`, `DeviceAuthorization`, - `AuthProject`, `IntrospectResponse`, `DevicePollResult`, `RevokeResult`): - shaped after the Keboola auth-service JSON responses, never persisted. + `AuthProject`, `IntrospectResponse`, `DevicePollResult`, `RevokeResult`, + `SudoResult`, `PatItem`, `PatCreateResult`): shaped after the Keboola + auth-service JSON responses, never persisted. - Persisted state (`StackSession`, `AuthState`): the exact shape written to and read from ``auth.json`` by `AuthStateStore`. @@ -188,6 +189,50 @@ class RevokeResult: message: str = "" +@dataclass(frozen=True) +class SudoResult: + """Outcome of POST /v1/auth/sudo (step-up authentication). + + Carries no token -- the sudo window is server-side state on the existing + session, not a new credential. ``expires_at`` is the raw RFC 3339 string + from the response, kept as-is since it is only ever displayed, never + computed on. + """ + + verified: bool + expires_at: str = "" + timeout_seconds: int = 0 + + +class PatItem(BaseModel): + """A Personal Access Token's metadata. Never carries the secret value.""" + + id: str + name: str + read_only: bool = Field(default=False, alias="readOnly") + expires_at: datetime | None = Field(default=None, alias="expiresAt") + created_at: datetime | None = Field(default=None, alias="createdAt") + + model_config = _WIRE_MODEL_CONFIG + + +class PatCreateResult(BaseModel): + """Response to POST /v1/auth/pat. + + ``access_token`` is the PAT's bearer value, shown exactly once by this + response and never retrievable again -- callers must print it and not + persist it (mirrors the "no token value is ever logged" rule the session + login flow already follows). + """ + + access_token: str = Field(alias="accessToken") + token_type: str = Field(default="Bearer", alias="tokenType") + expires_in: int = Field(default=0, alias="expiresIn") + pat: PatItem + + model_config = _WIRE_MODEL_CONFIG + + class StackSession(BaseModel): """One persisted programmatic-auth session, keyed by normalized stack URL. diff --git a/src/keboola_agent_cli/auth/token_provider.py b/src/keboola_agent_cli/auth/token_provider.py index 7209efee..2329cc7f 100644 --- a/src/keboola_agent_cli/auth/token_provider.py +++ b/src/keboola_agent_cli/auth/token_provider.py @@ -462,6 +462,25 @@ def _stamp(self, request: httpx.Request, token: str) -> None: request.headers["X-KBC-ProjectId"] = str(self._project_id) +class StaticBearerAuth(httpx.Auth): + """httpx auth hook that stamps a fixed `Authorization: Bearer` value. + + The PAT counterpart of `BearerAuth`: a Personal Access Token has no + refresh token and does not rotate, so it needs none of `BearerAuth`'s + `TokenProvider`/401-retry machinery -- it behaves like a static Storage + token that happens to go on a different header. When it expires or is + revoked, the fix is the same as for a stale static token: mint a new one + and update the secret, not an automatic refresh. + """ + + def __init__(self, token: str) -> None: + self._token = token + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self._token}" + yield request + + def get_session_token_provider(stack_url: str, state_store: AuthStateStore) -> SessionTokenProvider: """Return the process-wide provider for (state_store.state_path, normalized stack_url). diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 46bf042f..2b65938c 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,26 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.81.0": [ + "New: `kbagent auth pat-create --name NAME [--totp-code CODE] [--read-only] " + "[--ttl-days N]` -- mint a Personal Access Token (`kbc_pat_...`) from an EXISTING " + "`auth login` session, for one-time CI/CD setup. Does a TOTP step-up " + "(`POST /v1/auth/sudo`) then mints the token (`POST /v1/auth/pat`); `--totp-code` is " + "prompted interactively when omitted and is REQUIRED under `--json` or a non-TTY " + "stdin -- there is no unattended path to mint a PAT, the same human-required boundary " + "as `auth login` itself. The token is printed exactly once and never stored by kbagent.", + "New: `kbagent auth pat-revoke PAT_ID [--yes]` -- revoke a Personal Access Token. No " + "sudo step-up needed; idempotent (revoking an already-revoked id is not an error).", + "New: a `kbc_pat_...` token now works as a drop-in for `KBC_TOKEN` " + "(`KBAGENT_PROJECT_FROM_ENV=1`) or `project add --token` -- `make_client_factory` " + "recognizes the prefix on a plain static token and sends it as `Authorization: Bearer` " + "instead of `X-StorageApi-Token` (the Storage API treats these as distinct auth " + "schemes, not interchangeable encodings of one), so a PAT reaches every command that " + "already works on a session project (`sync`, `storage`, `config`, ...). Unlike a " + "session, a PAT does not rotate -- replace it (`pat-create` again) rather than " + "expecting an automatic refresh.", + "New error codes: `AUTH_SUDO_REQUIRED`, `AUTH_MFA_INVALID`.", + ], "0.80.0": [ "New: `kbagent auth login|status|logout` -- browser-based programmatic authentication as " "an alternative to a long-lived static Storage API token. `login` signs in via PKCE " diff --git a/src/keboola_agent_cli/commands/auth.py b/src/keboola_agent_cli/commands/auth.py index 8b493cb0..4f62a4ee 100644 --- a/src/keboola_agent_cli/commands/auth.py +++ b/src/keboola_agent_cli/commands/auth.py @@ -12,7 +12,13 @@ this is not something an AI agent can complete unattended. The resulting session tokens are never printed or retrievable via the CLI; every result below is built from a dataclass with no token field, so `--json` output is -safe by construction. +safe by construction -- with one deliberate exception: `pat-create` +(`PatCreateCliResult`), whose entire purpose is to show a newly-minted +Personal Access Token exactly once so the operator can copy it into a CI +secret. `pat-create`/`pat-revoke` also need an already-live session (from +`auth login`) plus a fresh TOTP code, but neither opens a browser itself -- +minting or revoking a PAT is an ordinary bearer-authenticated API call once +a session exists. """ from __future__ import annotations @@ -35,6 +41,8 @@ AuthStatusResult, LoginResult, LogoutResult, + PatCreateCliResult, + PatRevokeResult, ProjectSelection, RegisteredProject, RegisterProjectsResult, @@ -265,6 +273,30 @@ def _format_register_projects_result(console: Console, result: RegisterProjectsR _render_session_restrictions(console, result.session_unsupported_features) +def _format_pat_create_result(console: Console, result: PatCreateCliResult) -> None: + """Render a freshly-minted PAT. This is the one place a token is ever printed.""" + console.print(f"[bold green]PAT created[/bold green] on {escape(result.stack_url)}.") + console.print( + "[bold yellow]This value is shown ONLY ONCE -- copy it into a CI secret now:[/bold yellow]" + ) + console.print(result.access_token) + scope = "read-only" if result.read_only else "read-write" + console.print( + f"[dim]name={escape(result.name)} scope={scope} expires_in={result.expires_in}s[/dim]" + ) + + +def _format_pat_revoke_result(console: Console, result: PatRevokeResult) -> None: + """Render a PAT revocation, surfacing an uncertain remote revoke distinctly.""" + if result.status == "ok": + console.print(f"[bold green]PAT revoked[/bold green] ({escape(result.pat_id)}).") + else: + console.print( + f"[bold yellow]Could not confirm revocation[/bold yellow] of PAT " + f"{escape(result.pat_id)}. {escape(result.detail)}" + ) + + # ── Commands ────────────────────────────────────────────────────────── @@ -530,6 +562,95 @@ def auth_register_projects( formatter.output(result, _format_register_projects_result) +@auth_app.command("pat-create") +def auth_pat_create( + ctx: typer.Context, + name: str = typer.Option(..., "--name", help="Label for the PAT (shown in future listings)"), + stack: str | None = typer.Option( + None, "--stack", help="Stack URL or a registered project alias with a live session" + ), + totp_code: str | None = typer.Option( + None, + "--totp-code", + help="Current 6-digit TOTP code. Omitted: prompted interactively " + "(never accept this as a hardcoded value in a script -- it is a live, " + "30-second code you type from your authenticator app each time).", + ), + read_only: bool = typer.Option( + False, "--read-only", help="Issue a read-only PAT (denies writes on Storage routes)" + ), + ttl_days: int | None = typer.Option( + None, "--ttl-days", help="PAT lifetime in days (default: org policy maximum)" + ), +) -> None: + """Mint a Personal Access Token from the current session, for one-time CI/CD setup. + + Requires `kbagent auth login` to already be signed in on this stack -- + this command spends that session's access token, it does not start a new + login. Also requires a live TOTP code (step-up authentication), because + the auth service will not mint a PAT without an active sudo window. + + The token is printed exactly once, in `access_token`, and never stored by + kbagent -- copy it into a CI secret immediately. Store it as `KBC_TOKEN` + (the `KBAGENT_PROJECT_FROM_ENV=1` headless path) or via + `project add --token`: kbagent recognizes the `kbc_pat_...` prefix and + sends it as `Authorization: Bearer`, the same auth scheme a browser-login + session uses, so every command that already works on a session project + (`sync`, `storage`, `config`, ...) works the same way with a PAT -- with + the difference that a PAT does not rotate, so replace it (this command + again) instead of expecting an automatic refresh. + """ + formatter = get_formatter(ctx) + check_cli_operation(ctx, "auth.pat-create") + if totp_code is None: + if formatter.json_mode or not _is_stdout_tty(): + formatter.error( + message="--totp-code is required in --json mode or when stdin is not a TTY.", + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) + totp_code = typer.prompt("Current TOTP code") + service: AuthService = get_service(ctx, "auth_service") + try: + result = service.create_pat( + stack=stack, + totp_code=totp_code, + name=name, + read_only=read_only, + expires_in=ttl_days * 86400 if ttl_days else None, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_pat_create_result) + + +@auth_app.command("pat-revoke") +def auth_pat_revoke( + ctx: typer.Context, + pat_id: str = typer.Argument(..., help="PAT id (UUID) to revoke"), + stack: str | None = typer.Option( + None, "--stack", help="Stack URL or a registered project alias with a live session" + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Revoke a Personal Access Token. Idempotent -- an already-revoked id is not an error.""" + formatter = get_formatter(ctx) + check_cli_operation(ctx, "auth.pat-revoke") + if ( + not formatter.json_mode + and not yes + and not typer.confirm(f"Revoke PAT {pat_id}? Anything using it will stop working.") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + service: AuthService = get_service(ctx, "auth_service") + try: + result = service.revoke_pat(stack=stack, pat_id=pat_id) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_pat_revoke_result) + + def _pick_projects_interactively( formatter: OutputFormatter, service: AuthService, diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index a20e7010..3d4e67f1 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -140,6 +140,31 @@ hint to run this command later. A failure in this optional follow-up never changes login's own (already-successful) exit code. + kbagent auth pat-create --name NAME [--stack URL|alias] [--totp-code CODE] [--read-only] [--ttl-days N] + kbagent auth pat-revoke PAT_ID [--stack URL|alias] [--yes] + Mint or revoke a Personal Access Token (kbc_pat_...) -- the sanctioned + way to give CI/CD a long-lived credential instead of a raw Storage + token. pat-create spends an EXISTING `auth login` session's access + token (it does NOT log in itself) to do a TOTP step-up + (POST /v1/auth/sudo) then mint the token (POST /v1/auth/pat). + --totp-code is prompted interactively when omitted, and is REQUIRED + under --json or a non-TTY stdin -- there is no unattended path to mint + a PAT, same "needs a human" boundary as `auth login` itself. AN AI + AGENT MUST NOT invent or guess a TOTP code; either the human supplies + --totp-code, or the agent must not run this command at all. + The token is printed exactly once (in `access_token`) and never stored + by kbagent -- copy it into a CI secret immediately. Store it as + KBC_TOKEN under KBAGENT_PROJECT_FROM_ENV=1, or via `project add + --token`: services/base.py's make_client_factory recognizes the + kbc_pat_ prefix on a plain static token and sends it as + `Authorization: Bearer` instead of `X-StorageApi-Token` (different + auth schemes on the Storage API, not different encodings of one), so + every command that already works on a session project (sync, storage, + config, ...) works the same way with a PAT. A PAT does not rotate + (unlike a session) -- replace it (pat-create again) rather than + expecting an automatic refresh. pat-revoke needs no step-up and is + idempotent (revoking an already-revoked id is not an error). + Storage posture: session tokens live in PLAINTEXT in auth.json (0600), a sibling of config.json -- the same posture as the static Storage tokens already kept there (deliberate RFC 8628 deviation; see diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index b62d8b6d..4fc89f3b 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -637,6 +637,17 @@ def _resolve_app_name() -> str: # kbagent builds still load the file. SESSION_TOKEN_PREFIX: str = "kbc-session://" +# Prefix on the *real* bearer token value (not a config.json sentinel) that a +# Personal Access Token comes back as from `POST /v1/auth/pat`. Recognised by +# `services/base.py`'s `make_client_factory` so a PAT dropped into a plain +# static-token slot (`project add --token`, or `KBC_TOKEN` under +# `KBAGENT_PROJECT_FROM_ENV=1`) is sent as `Authorization: Bearer`, matching +# what the Storage API's own `BearerAuth` security scheme expects -- a literal +# `kbc_pat_...` string sent as `X-StorageApi-Token` is rejected outright, since +# that header is a distinct auth scheme from Bearer, not an alternate encoding +# of it. +PAT_ACCESS_TOKEN_PREFIX: str = "kbc_pat_" + # Server endpoints, relative to the stack base URL. AUTH_PKCE_AUTHORIZE_PATH: str = "/admin/auth/pkce/authorize" AUTH_PKCE_TOKEN_PATH: str = "/v1/auth/pkce/token" @@ -650,6 +661,11 @@ def _resolve_app_name() -> str: # which only ever has a session id on hand, never that session's refresh # token -- see plan review B-1/B-2). AUTH_SESSIONS_PATH: str = "/v1/auth/sessions" +# Step-up (sudo) and Personal Access Token endpoints (since 0.81.0). A sudo +# window must be active on the current session before `AUTH_PAT_PATH` (POST) +# will mint a token -- see `AuthService.create_pat`. +AUTH_SUDO_PATH: str = "/v1/auth/sudo" +AUTH_PAT_PATH: str = "/v1/auth/pat" AUTH_DEVICE_DEFAULT_INTERVAL: int = 5 # RFC 8628 default poll interval (s) AUTH_DEVICE_MAX_INTERVAL: int = 60 # cap after repeated slow_down diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index bfe912cb..c4048935 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -137,6 +137,10 @@ class ErrorCode(StrEnum): SESSION_EXPIRED = "SESSION_EXPIRED" SESSION_NOT_FOUND = "SESSION_NOT_FOUND" + # Personal Access Tokens / sudo step-up (since 0.81.0) + AUTH_SUDO_REQUIRED = "AUTH_SUDO_REQUIRED" + AUTH_MFA_INVALID = "AUTH_MFA_INVALID" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. @@ -317,6 +321,8 @@ def __init__(self, feature: str, *, remedy: str = "") -> None: ErrorCode.AUTH_STATE_MISMATCH: "authentication", ErrorCode.SESSION_EXPIRED: "authentication", ErrorCode.SESSION_NOT_FOUND: "authentication", + ErrorCode.AUTH_SUDO_REQUIRED: "authentication", + ErrorCode.AUTH_MFA_INVALID: "authentication", } diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index a3d54c67..b07b5659 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -25,6 +25,12 @@ # tokens, never a real credential) -- same risk class as login/logout, # not the "admin" class `project add` uses for a pasted static token. "auth.register-projects": "write", + # pat-create mints a durable, usable-anywhere credential (unlike a + # sentinel, it IS the bearer value) -- same "admin" class as `project + # add`'s pasted static token. pat-revoke is the same class as + # `project remove`: it can cut off whatever CI/CD is using that PAT. + "auth.pat-create": "admin", + "auth.pat-revoke": "admin", # Project management "project.add": "admin", "project.list": "read", diff --git a/src/keboola_agent_cli/services/auth_service.py b/src/keboola_agent_cli/services/auth_service.py index 817d319d..e57d2f2b 100644 --- a/src/keboola_agent_cli/services/auth_service.py +++ b/src/keboola_agent_cli/services/auth_service.py @@ -159,6 +159,34 @@ class LogoutResult: orphans_remaining: list[str] +@dataclass(frozen=True) +class PatCreateCliResult: + """Result of `kbagent auth pat-create`. + + Unlike every other result in this module, this ONE legitimately carries + a token value -- a PAT is shown exactly once, by design, and this is + that one display. It must never be written to a log or persisted + anywhere by kbagent itself. + """ + + status: str # "ok" + stack_url: str + pat_id: str + name: str + read_only: bool + expires_in: int + access_token: str + + +@dataclass(frozen=True) +class PatRevokeResult: + """Result of `kbagent auth pat-revoke`.""" + + status: str # "ok" | "unconfirmed" + pat_id: str + detail: str = "" + + AuthClientFactory = Callable[[str], AuthClient] @@ -751,6 +779,98 @@ def _retry_orphans( remaining.append(orphan_id) return _OrphanRetryOutcome(revoked=revoked, remaining=remaining) + # ------------------------------------------------------------------ + # PAT (Personal Access Token) provisioning + # ------------------------------------------------------------------ + + def create_pat( + self, + *, + stack: str | None, + totp_code: str, + name: str, + read_only: bool = False, + expires_in: int | None = None, + ) -> PatCreateCliResult: + """Mint a PAT from the already-logged-in session on this stack. + + This does NOT log in or open a browser -- it spends an *existing* + session's access token to complete the sudo-then-create-PAT sequence, + which are ordinary bearer-authenticated API calls once a live session + exists (unlike `auth login` itself, neither needs a browser). Run + `kbagent auth login` first if there is no stored session yet. + + The intended use is one-time CI/CD setup: mint a long-lived, scoped + PAT here interactively, then store `access_token` as a CI secret and + use it in place of a Storage token (`KBC_TOKEN` under + `KBAGENT_PROJECT_FROM_ENV=1`, or `project add --token`) -- kbagent + recognizes the `kbc_pat_...` prefix and sends it as + `Authorization: Bearer` instead of `X-StorageApi-Token` + (`services/base.py`'s `make_client_factory`). + """ + stack_url = self._resolve_stack_url(stack) + session = self._state_store.get_session(stack_url) + if session is None: + raise ConfigError( + f"No stored session for {stack_url}. Run `kbagent auth login " + f"--stack {stack_url}` first -- a PAT is minted FROM an existing " + "session, it does not create one." + ) + + provider = SessionTokenProvider( + stack_url, self._state_store, client_factory=self._auth_client_factory + ) + access_token = provider.get_access_token() + with self._auth_client_factory(stack_url) as client: + sudo = client.sudo_totp(access_token, totp_code) + if not sudo.verified: + raise KeboolaApiError( + message="Sudo step-up was not verified. Check the TOTP code and try again.", + status_code=401, + error_code=ErrorCode.AUTH_SUDO_REQUIRED, + retryable=False, + ) + result = client.create_pat( + access_token, name=name, read_only=read_only, expires_in=expires_in + ) + + return PatCreateCliResult( + status="ok", + stack_url=stack_url, + pat_id=result.pat.id, + name=result.pat.name, + read_only=result.pat.read_only, + expires_in=result.expires_in, + access_token=result.access_token, + ) + + def revoke_pat(self, *, stack: str | None, pat_id: str) -> PatRevokeResult: + """Revoke a PAT using the already-logged-in session on this stack. + + No sudo step-up needed -- unlike minting, revocation is not gated + behind a sudo window on this endpoint. + """ + stack_url = self._resolve_stack_url(stack) + session = self._state_store.get_session(stack_url) + if session is None: + raise ConfigError( + f"No stored session for {stack_url}. Run `kbagent auth login " + f"--stack {stack_url}` first." + ) + + provider = SessionTokenProvider( + stack_url, self._state_store, client_factory=self._auth_client_factory + ) + access_token = provider.get_access_token() + with self._auth_client_factory(stack_url) as client: + result = client.revoke_pat(access_token, pat_id) + + return PatRevokeResult( + status="ok" if result.confirmed else "unconfirmed", + pat_id=pat_id, + detail=result.message, + ) + # ------------------------------------------------------------------ # shared # ------------------------------------------------------------------ @@ -795,6 +915,8 @@ def _resolve_stack_url(self, stack: str | None) -> str: "AuthStatusResult", "LoginResult", "LogoutResult", + "PatCreateCliResult", + "PatRevokeResult", "ProjectCandidate", "ProjectCandidatesResult", "ProjectSelection", diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index b8e4e5af..ff634194 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -14,7 +14,11 @@ from ..auth.sentinel import is_session_token, parse_session_project_id, require_static_token from ..client import KeboolaClient from ..config_store import ConfigError, ConfigStore, project_not_found_error -from ..constants import ENV_MAX_PARALLEL_WORKERS, UNEXPECTED_ERROR_MAX_MESSAGE_LEN +from ..constants import ( + ENV_MAX_PARALLEL_WORKERS, + PAT_ACCESS_TOKEN_PREFIX, + UNEXPECTED_ERROR_MAX_MESSAGE_LEN, +) from ..errors import ErrorCode from ..models import ProjectConfig @@ -136,16 +140,27 @@ def make_client_factory(config_store: ConfigStore) -> ClientFactory: detected here, the project id is parsed out of the sentinel itself (the one datum the 2-arg signature otherwise lacks), and the client is built with `http_auth=BearerAuth(...)` instead of a static `X-StorageApi-Token`. - A plain static token takes the unchanged, byte-identical path. + A plain static token takes the unchanged, byte-identical path -- EXCEPT + for a Personal Access Token (`kbc_pat_...`), recognized by its literal + prefix rather than a sentinel (a PAT is a real, usable credential, so + unlike a session it can sit directly in `ProjectConfig.token` or + `KBC_TOKEN`, but it must still go out as `Authorization: Bearer`, not + `X-StorageApi-Token` -- those are different auth schemes on the Storage + API, not different encodings of the same one). `auth.state_store` / `auth.token_provider` are imported lazily inside the returned closure (not at module level) so the static-token startup path never pays for constructing the auth package's heavier dependencies - (filelock, httpx client machinery) -- only a session-registered project - ever reaches that branch. + (filelock, httpx client machinery) -- only a session-registered or + PAT-credentialed project ever reaches those branches. """ def _factory(stack_url: str, token: str) -> KeboolaClient: + if token.startswith(PAT_ACCESS_TOKEN_PREFIX): + from ..auth.token_provider import StaticBearerAuth + + return KeboolaClient(stack_url=stack_url, token="", http_auth=StaticBearerAuth(token)) + if not is_session_token(token): return KeboolaClient(stack_url=stack_url, token=token) diff --git a/tests/test_auth_client.py b/tests/test_auth_client.py index 9a7a4d31..50835730 100644 --- a/tests/test_auth_client.py +++ b/tests/test_auth_client.py @@ -28,7 +28,9 @@ DeviceAuthorization, DevicePollStatus, IntrospectResponse, + PatCreateResult, RevokeResult, + SudoResult, ) from keboola_agent_cli.commands._helpers import map_error_to_exit_code from keboola_agent_cli.constants import ( @@ -1280,3 +1282,182 @@ def test_404_maps_to_not_supported(self, httpx_mock, path: str) -> None: assert excinfo.value.error_code == ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK assert STACK_URL in excinfo.value.message + + +class TestSudoTotp: + def test_verified_stamps_bearer_and_body(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/sudo", + method="POST", + status_code=200, + json={ + "sudoVerified": True, + "sudoExpiresAt": "2026-01-01T00:05:00Z", + "sudoTimeoutSeconds": 300, + }, + ) + client = _make_client() + try: + result = client.sudo_totp("kbc_at_live", "123456") + finally: + client.close() + + assert isinstance(result, SudoResult) + assert result.verified is True + assert result.timeout_seconds == 300 + + request = httpx_mock.get_requests()[0] + assert request.headers["Authorization"] == "Bearer kbc_at_live" + assert json.loads(request.read().decode()) == {"type": "totp", "totpCode": "123456"} + + def test_not_verified(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/sudo", + method="POST", + status_code=200, + json={"sudoVerified": False, "sudoExpiresAt": "", "sudoTimeoutSeconds": 0}, + ) + client = _make_client() + try: + result = client.sudo_totp("kbc_at_live", "000000") + finally: + client.close() + assert result.verified is False + + def test_404_maps_to_auth_not_supported(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/sudo", method="POST", status_code=404, json={} + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.sudo_totp("kbc_at_live", "123456") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK + + +class TestCreatePat: + def test_minimal_request(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/pat", + method="POST", + status_code=201, + json={ + "accessToken": "kbc_pat_abc123", + "tokenType": "Bearer", + "expiresIn": 7776000, + "pat": { + "id": "pat-1", + "name": "ci-salesforce", + "scope": {"all": True}, + "projects": [], + "readOnly": False, + "expiresAt": "2026-04-01T00:00:00Z", + "createdAt": "2026-01-01T00:00:00Z", + }, + }, + ) + client = _make_client() + try: + result = client.create_pat("kbc_at_live", name="ci-salesforce") + finally: + client.close() + + assert isinstance(result, PatCreateResult) + assert result.access_token == "kbc_pat_abc123" + assert result.pat.id == "pat-1" + assert result.pat.read_only is False + + request = httpx_mock.get_requests()[0] + assert request.headers["Authorization"] == "Bearer kbc_at_live" + assert json.loads(request.read().decode()) == {"name": "ci-salesforce"} + + def test_read_only_and_ttl_in_body(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/pat", + method="POST", + status_code=201, + json={ + "accessToken": "kbc_pat_ro", + "expiresIn": 86400, + "pat": { + "id": "pat-2", + "name": "n", + "scope": {"all": True, "readOnly": True}, + "projects": [], + "readOnly": True, + "expiresAt": "2026-01-02T00:00:00Z", + "createdAt": "2026-01-01T00:00:00Z", + }, + }, + ) + client = _make_client() + try: + client.create_pat("kbc_at_live", name="n", read_only=True, expires_in=86400) + finally: + client.close() + + request = httpx_mock.get_requests()[0] + assert json.loads(request.read().decode()) == { + "name": "n", + "expiresIn": 86400, + "scope": {"all": True, "readOnly": True}, + } + + def test_sudo_not_active_raises(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/pat", + method="POST", + status_code=403, + json={"error": "Sudo window not active."}, + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError): + client.create_pat("kbc_at_live", name="n") + finally: + client.close() + + +class TestRevokePat: + def test_confirmed(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/pat/pat-1", method="DELETE", status_code=204 + ) + client = _make_client() + try: + result = client.revoke_pat("kbc_at_live", "pat-1") + finally: + client.close() + assert isinstance(result, RevokeResult) + assert result.confirmed is True + + request = httpx_mock.get_requests()[0] + assert request.headers["Authorization"] == "Bearer kbc_at_live" + + def test_already_revoked_404_is_confirmed(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/pat/pat-1", method="DELETE", status_code=404, json={} + ) + client = _make_client() + try: + result = client.revoke_pat("kbc_at_live", "pat-1") + finally: + client.close() + assert result.confirmed is True + + def test_server_error_never_raises(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/pat/pat-1", + method="DELETE", + status_code=500, + json={"error": "internal error"}, + ) + client = _make_client() + try: + result = client.revoke_pat("kbc_at_live", "pat-1") + finally: + client.close() + assert result.confirmed is False + assert result.message diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py index 8b8a02d1..03fb6d21 100644 --- a/tests/test_auth_service.py +++ b/tests/test_auth_service.py @@ -22,7 +22,10 @@ CliTokenResponse, DeviceAuthorization, IntrospectResponse, + PatCreateResult, + PatItem, RevokeResult, + SudoResult, ) from keboola_agent_cli.auth.pkce import ( LoopbackCallback, @@ -39,6 +42,8 @@ from keboola_agent_cli.services.auth_service import ( SESSION_UNSUPPORTED_FEATURES, AuthService, + PatCreateCliResult, + PatRevokeResult, ProjectSelection, ) @@ -67,6 +72,15 @@ def __init__(self) -> None: self.refresh_side_effect: Exception | None = None self.revoke_result = RevokeResult(confirmed=True) self.delete_session_result = RevokeResult(confirmed=True) + self.sudo_result = SudoResult( + verified=True, expires_at="2026-01-01T00:05:00Z", timeout_seconds=300 + ) + self.pat_create_response = PatCreateResult( + accessToken="kbc_pat_abc123", + expiresIn=7776000, + pat=PatItem(id="pat-1", name="ci-token", readOnly=False), + ) + self.revoke_pat_result = RevokeResult(confirmed=True) def __enter__(self) -> _FakeAuthClient: return self @@ -110,6 +124,18 @@ def delete_session(self, session_id: str, access_token: str) -> RevokeResult: self.calls.append(("delete_session", (session_id, access_token))) return self.delete_session_result + def sudo_totp(self, access_token: str, totp_code: str): + self.calls.append(("sudo_totp", (access_token, totp_code))) + return self.sudo_result + + def create_pat(self, access_token: str, **kwargs: Any): + self.calls.append(("create_pat", (access_token, kwargs))) + return self.pat_create_response + + def revoke_pat(self, access_token: str, pat_id: str) -> RevokeResult: + self.calls.append(("revoke_pat", (access_token, pat_id))) + return self.revoke_pat_result + class _FakeCallbackServer: """Stand-in for `PkceCallbackServer`: succeeds with a fixed callback.""" @@ -1243,3 +1269,87 @@ def delete_session(self, session_id: str, access_token: str) -> RevokeResult: assert outcome.revoked == ["gone"] assert outcome.remaining == ["stuck"] + + +class TestCreatePat: + def test_no_session_raises_config_error(self, store, state_store) -> None: + client = _FakeAuthClient() + service = _make_service(store, state_store, client) + with pytest.raises(ConfigError): + service.create_pat(stack=STACK_URL, totp_code="123456", name="ci") + + def test_success_does_sudo_then_create_with_live_access_token(self, store, state_store) -> None: + state_store.put_session(_existing_session(session_id="sess-1", refresh_token="rt-1")) + client = _FakeAuthClient() + service = _make_service(store, state_store, client) + + result = service.create_pat(stack=STACK_URL, totp_code="123456", name="ci-salesforce") + + assert isinstance(result, PatCreateCliResult) + assert result.access_token == "kbc_pat_abc123" + assert result.pat_id == "pat-1" + assert result.name == "ci-token" + assert result.read_only is False + + # sudo MUST happen before create_pat, both with the session's live access token. + assert client.calls[0] == ("sudo_totp", ("old-at", "123456")) + create_call = client.calls[1] + assert create_call[0] == "create_pat" + assert create_call[1][0] == "old-at" + assert create_call[1][1] == { + "name": "ci-salesforce", + "read_only": False, + "expires_in": None, + } + + def test_sudo_not_verified_raises_sudo_required(self, store, state_store) -> None: + state_store.put_session(_existing_session(session_id="sess-1", refresh_token="rt-1")) + client = _FakeAuthClient() + client.sudo_result = SudoResult(verified=False) + service = _make_service(store, state_store, client) + + with pytest.raises(KeboolaApiError) as exc_info: + service.create_pat(stack=STACK_URL, totp_code="000000", name="ci") + assert exc_info.value.error_code == ErrorCode.AUTH_SUDO_REQUIRED + # create_pat must never be attempted once sudo failed. + assert not any(c[0] == "create_pat" for c in client.calls) + + def test_ttl_days_converted_to_seconds(self, store, state_store) -> None: + state_store.put_session(_existing_session(session_id="sess-1", refresh_token="rt-1")) + client = _FakeAuthClient() + service = _make_service(store, state_store, client) + + service.create_pat(stack=STACK_URL, totp_code="123456", name="ci", expires_in=90 * 86400) + + create_call = next(c for c in client.calls if c[0] == "create_pat") + assert create_call[1][1]["expires_in"] == 90 * 86400 + + +class TestRevokePat: + def test_no_session_raises_config_error(self, store, state_store) -> None: + client = _FakeAuthClient() + service = _make_service(store, state_store, client) + with pytest.raises(ConfigError): + service.revoke_pat(stack=STACK_URL, pat_id="pat-1") + + def test_confirmed_revoke(self, store, state_store) -> None: + state_store.put_session(_existing_session(session_id="sess-1", refresh_token="rt-1")) + client = _FakeAuthClient() + service = _make_service(store, state_store, client) + + result = service.revoke_pat(stack=STACK_URL, pat_id="pat-1") + + assert isinstance(result, PatRevokeResult) + assert result.status == "ok" + assert ("revoke_pat", ("old-at", "pat-1")) in client.calls + + def test_unconfirmed_revoke_reported_distinctly(self, store, state_store) -> None: + state_store.put_session(_existing_session(session_id="sess-1", refresh_token="rt-1")) + client = _FakeAuthClient() + client.revoke_pat_result = RevokeResult(confirmed=False, message="timed out") + service = _make_service(store, state_store, client) + + result = service.revoke_pat(stack=STACK_URL, pat_id="pat-1") + + assert result.status == "unconfirmed" + assert result.detail == "timed out" diff --git a/tests/test_base_service.py b/tests/test_base_service.py index 41b7448a..951b856a 100644 --- a/tests/test_base_service.py +++ b/tests/test_base_service.py @@ -26,6 +26,7 @@ ENV_MAX_PARALLEL_WORKERS, UNEXPECTED_ERROR_CODE, BaseService, + make_client_factory, project_error_entry, ) from keboola_agent_cli.services.mcp_service import MCP_ERROR_CODE @@ -545,3 +546,42 @@ def test_custom_client_factory_is_used(self, tmp_config_dir: Path) -> None: service = _TestService(config_store=store, client_factory=mock_factory) assert service._client_factory is mock_factory + + +class TestMakeClientFactoryPatDispatch: + """A `kbc_pat_...` static token must go out as Authorization: Bearer, + not X-StorageApi-Token -- those are different auth schemes on the + Storage API, confirmed against its own OpenAPI security schemes.""" + + def test_plain_static_token_unaffected(self, tmp_config_dir: Path) -> None: + store = setup_single_project(tmp_config_dir) + factory = make_client_factory(store) + client = factory("https://connection.keboola.com", "901-55555-someToken") + try: + assert client._client.headers.get("X-StorageApi-Token") == "901-55555-someToken" + assert client._client.auth is None + finally: + client.close() + + def test_pat_token_routes_to_bearer_auth(self, tmp_config_dir: Path) -> None: + from keboola_agent_cli.auth.token_provider import StaticBearerAuth + + store = setup_single_project(tmp_config_dir) + factory = make_client_factory(store) + client = factory("https://connection.keboola.com", "kbc_pat_abc123") + try: + assert "X-StorageApi-Token" not in client._client.headers + assert isinstance(client._client.auth, StaticBearerAuth) + finally: + client.close() + + def test_pat_bearer_auth_stamps_the_token_value(self, tmp_config_dir: Path) -> None: + import httpx + + from keboola_agent_cli.auth.token_provider import StaticBearerAuth + + auth = StaticBearerAuth("kbc_pat_abc123") + request = httpx.Request("GET", "https://connection.keboola.com/v2/storage/buckets") + flow = auth.auth_flow(request) + stamped = next(flow) + assert stamped.headers["Authorization"] == "Bearer kbc_pat_abc123" diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index b5e11aee..9df4f3fa 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -25,6 +25,8 @@ AuthStatusResult, LoginResult, LogoutResult, + PatCreateCliResult, + PatRevokeResult, ProjectCandidate, ProjectCandidatesResult, RegisteredProject, @@ -109,6 +111,26 @@ def _candidate(**overrides: Any) -> ProjectCandidate: return ProjectCandidate(**defaults) # type: ignore[arg-type] +def _pat_create_result(**overrides: Any) -> PatCreateCliResult: + defaults: dict[str, Any] = { + "status": "ok", + "stack_url": STACK_URL, + "pat_id": "pat-1", + "name": "ci-salesforce", + "read_only": False, + "expires_in": 7776000, + "access_token": "kbc_pat_shownonceonly00000000", + } + defaults.update(overrides) + return PatCreateCliResult(**defaults) # type: ignore[arg-type] + + +def _pat_revoke_result(**overrides: Any) -> PatRevokeResult: + defaults: dict[str, Any] = {"status": "ok", "pat_id": "pat-1", "detail": ""} + defaults.update(overrides) + return PatRevokeResult(**defaults) # type: ignore[arg-type] + + def _register_result(**overrides: Any) -> RegisterProjectsResult: defaults: dict[str, Any] = { "status": "ok", @@ -980,3 +1002,128 @@ def test_plain_login_without_registering_prints_no_panel(self, tmp_path: Path) - result = _invoke(config_dir, svc, ["auth", "login", "--device-code"]) assert result.exit_code == 0, result.output assert "Not available on session-backed projects" not in result.output + + +class TestPatCreate: + def test_success_with_explicit_totp_code(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.create_pat.return_value = _pat_create_result() + result = _invoke( + config_dir, + svc, + ["auth", "pat-create", "--name", "ci-salesforce", "--totp-code", "123456"], + ) + assert result.exit_code == 0, result.output + assert "kbc_pat_shownonceonly00000000" in result.output + assert "ONLY ONCE" in result.output + svc.create_pat.assert_called_once_with( + stack=None, totp_code="123456", name="ci-salesforce", read_only=False, expires_in=None + ) + + def test_read_only_and_ttl_forwarded(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.create_pat.return_value = _pat_create_result(read_only=True) + result = _invoke( + config_dir, + svc, + [ + "auth", + "pat-create", + "--name", + "ci", + "--totp-code", + "123456", + "--read-only", + "--ttl-days", + "30", + ], + ) + assert result.exit_code == 0, result.output + svc.create_pat.assert_called_once_with( + stack=None, totp_code="123456", name="ci", read_only=True, expires_in=30 * 86400 + ) + + def test_json_mode_without_totp_code_fails_fast(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + result = _invoke(config_dir, svc, ["--json", "auth", "pat-create", "--name", "ci"]) + assert result.exit_code == 2, result.output + svc.create_pat.assert_not_called() + + def test_sudo_required_error_surfaces(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.create_pat.side_effect = KeboolaApiError( + "Sudo step-up was not verified.", error_code=ErrorCode.AUTH_SUDO_REQUIRED + ) + result = _invoke( + config_dir, + svc, + ["--json", "auth", "pat-create", "--name", "ci", "--totp-code", "000000"], + ) + assert result.exit_code != 0 + data = json.loads(result.stdout) + assert data["error"]["code"] == "AUTH_SUDO_REQUIRED" + + +class TestPatRevoke: + def test_confirmed_revoke(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.revoke_pat.return_value = _pat_revoke_result() + result = _invoke(config_dir, svc, ["auth", "pat-revoke", "pat-1", "--yes"]) + assert result.exit_code == 0, result.output + assert "revoked" in result.output.lower() + svc.revoke_pat.assert_called_once_with(stack=None, pat_id="pat-1") + + def test_confirm_abort(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + result = _invoke(config_dir, svc, ["auth", "pat-revoke", "pat-1"], input_text="n\n") + assert result.exit_code == 0 + assert "Aborted" in result.output + svc.revoke_pat.assert_not_called() + + def test_unconfirmed_revoke_reported_distinctly(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.revoke_pat.return_value = _pat_revoke_result(status="unconfirmed", detail="timed out") + result = _invoke(config_dir, svc, ["auth", "pat-revoke", "pat-1", "--yes"]) + assert result.exit_code == 0, result.output + assert "Could not confirm" in result.output + + +class TestPatPermissionClassification: + def test_registry_entries(self) -> None: + assert OPERATION_REGISTRY["auth.pat-create"] == "admin" + assert OPERATION_REGISTRY["auth.pat-revoke"] == "admin" + + def test_admin_deny_blocks_pat_create(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + (config_dir / "config.json").write_text( + json.dumps( + { + "version": CURRENT_CONFIG_VERSION, + "projects": {}, + "permissions": {"mode": "allow", "allow": [], "deny": ["cli:admin"]}, + } + ) + ) + svc = MagicMock() + result = _invoke( + config_dir, + svc, + ["--json", "auth", "pat-create", "--name", "ci", "--totp-code", "123456"], + ) + assert result.exit_code == EXIT_PERMISSION_DENIED + svc.create_pat.assert_not_called() diff --git a/uv.lock b/uv.lock index 0dd6f4bc..e08389fa 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.80.0" +version = "0.81.0" source = { editable = "." } dependencies = [ { name = "croniter" },