diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f85019..7b2999c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ called out explicitly under **Changed** or **Removed**. ## [Unreleased] +### Added + +- **`kizen service-accounts`** lists, creates, updates, deletes and shows + the history of integration service accounts, with permission groups by + name. API keys are never printed. + ## [0.3.0] — 2026-10-21 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08ef939..d9d9f26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,7 @@ src/kizen_builder/ records.py # record-level reads/writes (custom objects + built-ins) saved_views.py # filter groups / quick filters / column templates schema.py # schema lookups backing the filtering DSL + service_accounts.py # service-account CRUD + history (strips the key from create) smart_connectors.py # smart-connector CRUD, scripts, executions team.py # team-member reads + role changes tools/ # Tool functions. What the CLI mostly calls. @@ -132,6 +133,7 @@ src/kizen_builder/ plans.py # Plan / PlanOperation models, apply_plan() records.py # get_record(), search_records() saved_views.py # list/get filter groups, quick filters, column templates + service_accounts.py # resolve a service account; list/get/history smart_connectors/ # inspection + authoring + local pull/run/push dev loop __init__.py # re-export facade — `from ... import smart_connectors as sct` _common.py # MARKER_NAME, execution-metadata keys, shared helpers @@ -165,6 +167,7 @@ src/kizen_builder/ pipeline_stages.py # plan_create/update/remove stages, record stage-move records.py # plan_create/update/delete records (data, not schema) saved_views.py # plan_create/update/delete the three saved-view kinds + service_accounts.py # plan_create/update/delete integration service accounts team.py # plan_update_team_member_roles filtering.py # Filter DSL (Field, All/Any, search/filter-config rendering). translate.py # Automation GET → PUT wire translation. @@ -187,6 +190,7 @@ src/kizen_builder/ filters.py # kizen filters (the operator reference) team.py # kizen team permissions.py # kizen roles + kizen permissions + service_accounts.py # kizen service-accounts automations.py # kizen automations — reads, start, diagnostics steps.py # kizen automations steps messages.py # kizen messages diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index 2b1c4a6..446ce4d 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -32,6 +32,8 @@ │ team Look up team members and change their roles. │ │ roles Read and manage roles. │ │ permissions Read and manage permission groups + the permissions catalog. │ +│ service-accounts Read and manage integration service accounts (run-tools-as identities). See │ +│ `kizen docs show service-account`. │ │ automations Read, create, update, and run automations. │ │ messages Email templates and automation-scoped messages — the content │ │ notify_member_via_email steps reference (see `kizen docs show │ @@ -3372,6 +3374,126 @@ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Usage: service-accounts [OPTIONS] COMMAND [ARGS]... + + Read and manage integration service accounts (run-tools-as identities). See `kizen docs show + service-account`. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────╮ +│ list List service accounts with their groups and key count (previews only). │ +│ get Show one service account and its permission groups. │ +│ history Show a service account's history events. │ +│ create Create an integration service account. Its API key is never shown. │ +│ update Update an integration service account. Only the flags you pass change. │ +│ delete Delete (archive) an integration service account. This is permanent. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Usage: service-accounts create [OPTIONS] + + Create an integration service account. Its API key is never shown. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --name Account name; Kizen derives api_name. [required] │ +│ --group -g Permission group name or UUID (repeatable). With none, Kizen │ +│ creates the account disabled. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts delete [OPTIONS] {account} + + Delete (archive) an integration service account. This is permanent. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts get [OPTIONS] {account} + + Show one service account and its permission groups. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts history [OPTIONS] {account} + + Show a service account's history events. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --limit -n Max events to return. [default: 25] │ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts list [OPTIONS] + + List service accounts with their groups and key count (previews only). + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --search -s Filter by name. │ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts update [OPTIONS] {account} + + Update an integration service account. Only the flags you pass change. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --name Rename the account (api_name is kept). │ +│ --api-name Set the api_name. │ +│ --group -g Replace the account's permission groups with these (name or UUID, │ +│ repeatable). │ +│ --clear-groups Remove every permission group; Kizen disables the account. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + Usage: smart-connectors [OPTIONS] COMMAND [ARGS]... Build, inspect, and iterate on smart connectors. Read verbs @@ -4346,7 +4468,8 @@ `--check` is the session-start form: it reports whether a newer version exists, caches the answer for a day, and always exits 0 — offline, behind a - proxy, or with no remote configured, it just says so quietly. + proxy, or with no remote configured, it just says so quietly. Under + app-builder it says the installation is managed there, and still exits 0. ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ │ --check Only report whether a newer version exists; change nothing. │ @@ -4901,6 +5024,27 @@ ===== roles update ===== +===== service-accounts ===== + + +===== service-accounts create ===== + + +===== service-accounts delete ===== + + +===== service-accounts get ===== + + +===== service-accounts history ===== + + +===== service-accounts list ===== + + +===== service-accounts update ===== + + ===== smart-connectors ===== diff --git a/src/kizen_builder/api/service_accounts.py b/src/kizen_builder/api/service_accounts.py new file mode 100644 index 0000000..1e943b2 --- /dev/null +++ b/src/kizen_builder/api/service_accounts.py @@ -0,0 +1,78 @@ +"""Service accounts against the Kizen API (``/api/service-accounts``). + +A service account is a non-human employee that holds API keys and permission +groups. Integration accounts (``account_type: service_integration``) are the +ones a business creates; automation and plugin accounts are Kizen's own. + +Nothing here touches ``…/api-tokens/*``: the CLI never reads, reveals or +rotates a key. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api.client import KizenClient + +_BASE = "/api/service-accounts" + + +def list_service_accounts( + client: KizenClient, search: str | None = None +) -> list[dict[str, Any]]: + """GET /api/service-accounts — every account, previews only. + + ``search`` filters by name; the schema doesn't declare it, but it works + (confirmed live 2026-10-27). + """ + params: dict[str, Any] = {"page_size": 100} + if search: + params["search"] = search + return client.paginate(_BASE, params) + + +def get_service_account(client: KizenClient, account_id: str) -> dict[str, Any]: + """GET /api/service-accounts/{id} — the list row shape, never a key.""" + return client.get(f"{_BASE}/{account_id}") + + +def create_service_account( + client: KizenClient, payload: dict[str, Any] +) -> dict[str, Any]: + """POST /api/service-accounts — ``{name, permission_groups}``. + + The response carries the new account's API key in full under an + ``api_key`` object the schema doesn't declare (confirmed live 2026-10-27). + It is removed here, before anything else can see it, so no plan result, + table, log or ``--json`` output can contain it. + """ + resp = client.post(_BASE, json=payload) + if isinstance(resp, dict): + resp.pop("api_key", None) + return resp + + +def update_service_account( + client: KizenClient, account_id: str, payload: dict[str, Any] +) -> dict[str, Any]: + """PATCH /api/service-accounts/{id} — only the supplied keys change. + + A body that includes ``name`` re-derives ``api_name`` from it unless + ``api_name`` is sent too (confirmed live 2026-10-27). + """ + return client.patch(f"{_BASE}/{account_id}", json=payload) + + +def delete_service_account(client: KizenClient, account_id: str) -> None: + """DELETE /api/service-accounts/{id} — archives it; no read can see it after.""" + client.delete(f"{_BASE}/{account_id}") + + +def list_service_account_history( + client: KizenClient, account_id: str, limit: int +) -> list[dict[str, Any]]: + """GET /api/service-accounts/{id}/history — one page of up to ``limit`` events.""" + resp = client.get(f"{_BASE}/{account_id}/history", params={"page_size": limit}) + if isinstance(resp, dict): + return list(resp.get("results") or []) + return list(resp or []) diff --git a/src/kizen_builder/cli/__init__.py b/src/kizen_builder/cli/__init__.py index fdbff26..46033cf 100644 --- a/src/kizen_builder/cli/__init__.py +++ b/src/kizen_builder/cli/__init__.py @@ -37,6 +37,7 @@ from kizen_builder.cli import filters # noqa: F401 from kizen_builder.cli import team # noqa: F401 from kizen_builder.cli import permissions # noqa: F401 +from kizen_builder.cli import service_accounts # noqa: F401 from kizen_builder.cli import automations # noqa: F401 from kizen_builder.cli import steps # noqa: F401 from kizen_builder.cli import messages # noqa: F401 diff --git a/src/kizen_builder/cli/service_accounts.py b/src/kizen_builder/cli/service_accounts.py new file mode 100644 index 0000000..14e8575 --- /dev/null +++ b/src/kizen_builder/cli/service_accounts.py @@ -0,0 +1,286 @@ +"""`kizen service-accounts` — integration service accounts and their +permission groups. No command reads, reveals or prints an API key. +""" + +from __future__ import annotations + +from typing import Any + +import typer +from rich.markup import escape +from rich.table import Table +from rich.text import Text + +from kizen_builder import output as out +from kizen_builder.api.client import KizenClient +from kizen_builder.cli._mutations import _run_mutation +from kizen_builder.cli._shared import ( + JSON_OPTION, + OUTPUT_OPTION, + app, + cli_errors, + console, + err_console, +) +from kizen_builder.cli.permissions import _resolve_group_id +from kizen_builder.config import load_env_config +from kizen_builder.tools import service_accounts as sa_tools +from kizen_builder.tools.planners import service_accounts as sa_planners + +_EPILOG = "Endpoint behavior and quirks: see `kizen docs show service-account`" + +sa_app = typer.Typer( + help=( + "Read and manage integration service accounts (run-tools-as " + "identities). See `kizen docs show service-account`." + ), + no_args_is_help=True, +) +app.add_typer(sa_app, name="service-accounts") + +_REF_HELP = "Service account name, api_name or UUID." +_DRY_RUN = typer.Option(False, "--dry-run", help="Show the plan without applying.") +_YES = typer.Option(False, "--yes", "-y", help="Skip the y/N confirmation prompt.") +_JSON_WRITE = typer.Option( + False, "--json", help="Emit JSON (plan with --dry-run, results otherwise)." +) + + +def _resolve_id(ref: str) -> str: + with cli_errors(LookupError), KizenClient(load_env_config()) as client: + return sa_tools.resolve_service_account(client, ref)["id"] + + +def _group_names(row: dict[str, Any]) -> str: + return ", ".join( + g.get("name") or g.get("id") for g in row.get("permission_groups") or [] + ) + + +@sa_app.command("list", epilog=_EPILOG) +def sa_list( + search: str = typer.Option(None, "--search", "-s", help="Filter by name."), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """List service accounts with their groups and key count (previews only).""" + fmt = out.resolve_format(output, json_out) + with cli_errors(): + items = sa_tools.list_service_accounts(search=search) + + def table() -> None: + t = Table(title="Service accounts") + for col in ("name", "api_name", "type", "status", "groups"): + t.add_column(col) + t.add_column("keys", justify="right") + t.add_column("key preview", style="dim") + for a in items: + t.add_row( + Text(a.get("name") or "—"), + Text(a.get("api_name") or "—"), + a.get("account_type") or "—", + a.get("status") or "—", + Text(_group_names(a) or "—"), + str(a.get("api_token_count") or 0), + a.get("primary_api_token_preview") or "—", + ) + console.print(t) + if not items: + console.print("[dim]No service accounts.[/dim]") + + out.render( + fmt, + json_data=items, + table=table, + csv_rows=items, + csv_columns=[ + out.Column("id", "id"), + out.Column("name", "name"), + out.Column("api_name", "api_name"), + out.Column("account_type", "account_type"), + out.Column("status", "status"), + out.Column("groups", _group_names), + out.Column("api_token_count", "api_token_count"), + out.Column("primary_api_token_preview", "primary_api_token_preview"), + ], + ) + + +@sa_app.command("get", epilog=_EPILOG) +def sa_get( + account: str = typer.Argument(..., help=_REF_HELP), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show one service account and its permission groups.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + d = sa_tools.get_service_account(account) + + def table() -> None: + console.print( + f"[bold]{escape(d.get('name') or '—')}[/bold] [dim](id={d['id']})[/dim]", + emoji=False, + ) + for label, key in ( + ("api_name", "api_name"), + ("type", "account_type"), + ("status", "status"), + ("email", "email"), + ("keys", "api_token_count"), + ("key preview", "primary_api_token_preview"), + ("created", "created"), + ("last login", "last_login"), + ): + value = d.get(key) + console.print( + f"[dim]{label}:[/dim] {escape(str(value if value is not None else '—'))}", + emoji=False, + ) + t = Table(title="Permission groups") + t.add_column("name") + t.add_column("id", style="dim") + for g in d.get("permission_groups") or []: + t.add_row(Text(g.get("name") or "—"), g.get("id") or "—") + console.print(t) + if not d.get("permission_groups"): + console.print("[dim]No permission groups (the account is disabled).[/dim]") + + out.render(fmt, json_data=d, table=table) + + +@sa_app.command("history", epilog=_EPILOG) +def sa_history( + account: str = typer.Argument(..., help=_REF_HELP), + limit: int = typer.Option(25, "--limit", "-n", help="Max events to return."), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show a service account's history events.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + events = sa_tools.list_service_account_history(account, limit=limit) + + def by(e: dict[str, Any]) -> str: + who = e.get("initiator") or {} + full = " ".join(p for p in (who.get("first_name"), who.get("last_name")) if p) + return who.get("email") or full or "—" + + def table() -> None: + if not events: + console.print("No history.") + return + t = Table(title="History") + t.add_column("when", style="dim") + t.add_column("event") + t.add_column("by") + for e in events: + t.add_row( + e.get("created") or "—", + Text(e.get("type_display_name") or e.get("type_name") or "—"), + Text(by(e)), + ) + console.print(t) + + out.render( + fmt, + json_data=events, + table=table, + csv_rows=events, + csv_columns=[ + out.Column("when", "created"), + out.Column("event", "type_display_name"), + out.Column("by", by), + ], + ) + + +# --- mutations -------------------------------------------------------------- + + +@sa_app.command("create", epilog=_EPILOG) +def sa_create( + name: str = typer.Option( + ..., "--name", help="Account name; Kizen derives api_name." + ), + group: list[str] = typer.Option( + None, + "--group", + "-g", + help="Permission group name or UUID (repeatable). With none, Kizen " + "creates the account disabled.", + ), + dry_run: bool = _DRY_RUN, + yes: bool = _YES, + json_out: bool = _JSON_WRITE, +) -> None: + """Create an integration service account. Its API key is never shown.""" + group_ids = [_resolve_group_id(g) for g in (group or [])] + _run_mutation( + lambda: sa_planners.plan_create_service_account(name, group_ids), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) + + +@sa_app.command("update", epilog=_EPILOG) +def sa_update( + account: str = typer.Argument(..., help=_REF_HELP), + name: str = typer.Option( + None, "--name", help="Rename the account (api_name is kept)." + ), + api_name: str = typer.Option(None, "--api-name", help="Set the api_name."), + group: list[str] = typer.Option( + None, + "--group", + "-g", + help="Replace the account's permission groups with these (name or UUID, " + "repeatable).", + ), + clear_groups: bool = typer.Option( + False, + "--clear-groups", + help="Remove every permission group; Kizen disables the account.", + ), + dry_run: bool = _DRY_RUN, + yes: bool = _YES, + json_out: bool = _JSON_WRITE, +) -> None: + """Update an integration service account. Only the flags you pass change.""" + if group and clear_groups: + err_console.print("[red]error:[/red] pass --group or --clear-groups, not both.") + raise typer.Exit(code=2) + account_id = _resolve_id(account) + changes: dict[str, Any] = {} + if name is not None: + changes["name"] = name + if api_name is not None: + changes["api_name"] = api_name + if group: + changes["permission_groups"] = [_resolve_group_id(g) for g in group] + elif clear_groups: + changes["permission_groups"] = [] + _run_mutation( + lambda: sa_planners.plan_update_service_account(account_id, changes), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) + + +@sa_app.command("delete", epilog=_EPILOG) +def sa_delete( + account: str = typer.Argument(..., help=_REF_HELP), + dry_run: bool = _DRY_RUN, + yes: bool = _YES, + json_out: bool = _JSON_WRITE, +) -> None: + """Delete (archive) an integration service account. This is permanent.""" + account_id = _resolve_id(account) + _run_mutation( + lambda: sa_planners.plan_delete_service_account(account_id), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) diff --git a/src/kizen_builder/docs/commands.md b/src/kizen_builder/docs/commands.md index f515c50..3d153ac 100644 --- a/src/kizen_builder/docs/commands.md +++ b/src/kizen_builder/docs/commands.md @@ -72,6 +72,10 @@ kizen permissions groups # list permission groups (raw) kizen permissions group # one group as a sectioned permission map (UI-style sliders); --field-permissions adds per-field rows; --raw for wire JSON kizen permissions meta # the permissions catalog (sections, capabilities, defaults) +kizen service-accounts list [--search ] # name, api_name, type, status, groups, key count + preview (never a key) +kizen service-accounts get # one account's fields + permission groups +kizen service-accounts history [-n 25] # history events (when, event, by) + kizen activities list [--object ] [--search ] # activity types kizen activities get # one activity type: metadata, fields, visibility rules kizen activities fields list # fields on an activity type @@ -295,6 +299,9 @@ kizen team update [--role R ...] [--add-role R ...] [--remove-role R .. kizen permissions group-create --name X [--base default|clone] [--source-group ] [--settings-file f] kizen permissions group-update --settings-file f # raise/lower controls on an EXISTING group; same op shapes as group-create kizen permissions group-delete +kizen service-accounts create --name X [--group ...] # integration account; Kizen derives api_name; no groups = disabled +kizen service-accounts update [--name Y] [--api-name y] [--group ... | --clear-groups] # --group REPLACES the set; rename keeps api_name +kizen service-accounts delete # permanent archive; integration accounts only # patch one automation step (GET → translate → mutate node → validate → atomic PUT) kizen automations steps edit [--spec-file path | < stdin] diff --git a/src/kizen_builder/docs/reference.md b/src/kizen_builder/docs/reference.md index 714a3b5..67c7f89 100644 --- a/src/kizen_builder/docs/reference.md +++ b/src/kizen_builder/docs/reference.md @@ -24,6 +24,7 @@ the wire formats, endpoints, and confirmed quirks together. | Record layouts | `layout` | | Filter groups, quick filters, column templates | `saved-views` | | Roles & permission groups | `permission-group` | +| Service accounts (run-tools-as identities) | `service-account` | | Smart connectors (API + local dev loop) | `smart-connectors` | | A connector's flow spec (`configure-flow`) | `smart-connector-flow` | | Email templates & automation messages | `email-templates` | diff --git a/src/kizen_builder/docs/specs/README.md b/src/kizen_builder/docs/specs/README.md index e7b6537..adf6e34 100644 --- a/src/kizen_builder/docs/specs/README.md +++ b/src/kizen_builder/docs/specs/README.md @@ -27,6 +27,7 @@ field Kizen rejected. | `filter-groups`, `quick-filters`, `columns` create\|update | [saved-views.md](saved-views.md) | filter DSL / `configuration_json` | | `permissions group-create`, `roles …` | [permission-group.md](permission-group.md) | shaping-op list | | `team search\|get\|update` | [team.md](team.md) | — | +| `service-accounts …` (flag-driven) | [service-account.md](service-account.md) | — | | `smart-connectors` (all but `configure-flow`) | [smart-connectors.md](smart-connectors.md) | — | | `smart-connectors configure-flow` | [smart-connector-flow.md](smart-connector-flow.md) | execution variables + load steps | | `messages …` (and email template ground truth) | [email-templates.md](email-templates.md) | — | diff --git a/src/kizen_builder/docs/specs/service-account.md b/src/kizen_builder/docs/specs/service-account.md new file mode 100644 index 0000000..87fea06 --- /dev/null +++ b/src/kizen_builder/docs/specs/service-account.md @@ -0,0 +1,112 @@ +# Surface: service accounts + +**Consumed by:** `kizen service-accounts list|get|history|create|update|delete`. +Flag-driven; no spec shape. Run each command's `--help` for its flags. + +A service account is a non-human identity with permission groups and API keys. +A Call LLM step can run its MCP tools as one (`run_tools_as`). The CLI creates +and changes **integration** accounts only, and never reads, reveals or prints a +key. + +```bash +kizen service-accounts create --name "Ops Bot" -g "Integrations" --dry-run +kizen service-accounts update "Ops Bot" --group "Integrations" --group "Reporting" +``` + +Name accounts by **name** in specs and scripts. A CLI rename keeps the +api_name, but any rename that doesn't send it changes it (below). + +--- + +## Endpoints (confirmed live 2026-10-27) + +| Call | Used by | +|---|---| +| `GET /api/service-accounts` (page-number pagination, `?search=` by name) | `list`, every name lookup | +| `GET /api/service-accounts/{id}` | `get`, the update/delete planners | +| `POST /api/service-accounts` `{name, permission_groups}` | `create` | +| `PATCH /api/service-accounts/{id}` (only the keys sent change) | `update` | +| `DELETE /api/service-accounts/{id}` | `delete` | +| `GET /api/service-accounts/{id}/history` | `history` | + +`?search=` isn't in the schema, but it filters by name. + +Read rows: `id, name, api_name, account_type, status, email, +permission_groups[{id, name}], api_token_count, primary_api_token_preview, +secondary_api_token_preview, created, updated, last_login`. A read never +carries a key. + +## Account types + +- `service_integration`: accounts a business creates. The only type the CLI + updates or deletes. +- `service_automation`: Kizen's own automation account (`api_name` null). +- `service_automationplugin`: an account a plugin installs. **It isn't in the + schema's `account_type` enum** (`service_automation`, `service_integration`). + +`update` and `delete` refuse the last two at plan time. + +## api_name + +- **Kizen derives it from `name` on create and ignores a supplied one** + (`Ops Bot (MCP)!` → `ops_bot_mcp`). The plan previews the prediction; the + result reports what Kizen derived and flags a difference. +- **Any PATCH that includes `name` re-derives it**, even when the name is + unchanged. Sending `api_name` in the same body keeps it. `update --name` + therefore sends the current api_name with the new name, so a rename keeps + the identifier. +- `PATCH {api_name}` alone sets it, slugified (`"Bad Name!"` → `bad_name`). + +## Status follows the group set + +No permission groups means `status: "disabled"`; one or more means `active`. +That holds on create and on every PATCH. A `status` key in the body is +ignored silently. `suspended` is in the schema's enum but wasn't seen. + +## Duplicates + +- **A duplicate `name` on create answers 500**, and no account appears. The + create planner refuses a name that matches an existing account + case-insensitively, and a derived api_name another account holds. +- A duplicate api_name, or a rename onto an existing name or api_name, answers + 400 `{"name": ["A service account with this name already exists."]}`. The + error is keyed `name` even when the clash is the api_name. +- An unknown permission-group id answers 400 under `permission_groups`. + +## Delete is a permanent archive + +`DELETE` answers 204. Afterwards the account is gone from every read: GET, +list and typeahead give 404 or nothing, and a second DELETE or a PATCH gives +404. Nothing reachable through the API can see or restore it. The same name +can be created again at once. + +Not probed: whether its keys stop working, and what an automation whose +`run_tools_as` points at it reads back. + +## API keys + +- **The create response carries the new key in full**, under a top-level + `api_key` object the schema doesn't declare. The CLI drops `api_key` from + the response the moment it arrives, so no plan, result, table, log or + `--json` output can contain it. +- A read shows `primary_api_token_preview`: the key's first 3 characters + followed by asterisks, or null with no key. `api_token_count` is the number + of keys. +- The CLI calls no `…/api-tokens/*` endpoint. To see or rotate a key, use + Settings in Kizen. + +## History + +Page-number paginated events in the record-timeline shape (`created`, +`type_name`, `type_display_name`, `initiator{email, first_name, last_name}`, +…). An account created through the API has no events, even after renames and +group changes. + +## Run-tools-as + +`GET /api/automation2/automations/run-tools-as-options` lists the accounts a +Call LLM step can run its tools as. It includes `service_automation` and every +integration account, **even a disabled one**. It never lists the plugin +account. Whether a disabled account's tool calls succeed wasn't probed. + +Service accounts aren't team members: see [`team.md`](team.md). diff --git a/src/kizen_builder/docs/specs/team.md b/src/kizen_builder/docs/specs/team.md index 3e4a43e..fa81033 100644 --- a/src/kizen_builder/docs/specs/team.md +++ b/src/kizen_builder/docs/specs/team.md @@ -73,6 +73,9 @@ and falls back to `/api/team/typeahead` (name/email search) otherwise. sends the whole set, its replace-vs-merge semantics are undocumented, and the same body carries `fields` and `permission_groups`. +Service accounts are not team members: `/api/team` and its typeahead exclude +them (confirmed live 2026-10-27). See [`service-account.md`](service-account.md). + ## See also - [`permission-group.md`](permission-group.md) — `role -> group -> control`, diff --git a/src/kizen_builder/tools/permissions.py b/src/kizen_builder/tools/permissions.py index bba19b7..5fbd416 100644 --- a/src/kizen_builder/tools/permissions.py +++ b/src/kizen_builder/tools/permissions.py @@ -24,7 +24,7 @@ def level_label(value: Any) -> str: return str(value) -def _resolve(ref: str, items: list[dict[str, Any]], what: str) -> dict[str, Any]: +def resolve_by_name(ref: str, items: list[dict[str, Any]], what: str) -> dict[str, Any]: """Resolve ``ref`` (a UUID or an exact/case-insensitive name) to one item.""" from kizen_builder.utils import is_uuid @@ -51,14 +51,14 @@ def resolve_role(ref: str) -> dict[str, Any]: """Resolve a role by name or UUID → the role dict (id, name, ...).""" config = load_env_config() with KizenClient(config) as client: - return _resolve(ref, perm_api.list_roles(client), "role") + return resolve_by_name(ref, perm_api.list_roles(client), "role") def resolve_group(ref: str) -> dict[str, Any]: """Resolve a permission group by name or UUID → the group dict.""" config = load_env_config() with KizenClient(config) as client: - return _resolve( + return resolve_by_name( ref, perm_api.list_permission_groups(client), "permission group" ) @@ -107,7 +107,7 @@ def describe_role(role_ref: str) -> dict[str, Any]: """Resolve a role and expand its permission-group ids to names + summaries.""" config = load_env_config() with KizenClient(config) as client: - role = _resolve(role_ref, perm_api.list_roles(client), "role") + role = resolve_by_name(role_ref, perm_api.list_roles(client), "role") full = perm_api.get_role(client, role["id"]) names = { g["id"]: g.get("name") for g in perm_api.list_permission_groups(client) diff --git a/src/kizen_builder/tools/planners/service_accounts.py b/src/kizen_builder/tools/planners/service_accounts.py new file mode 100644 index 0000000..13f9837 --- /dev/null +++ b/src/kizen_builder/tools/planners/service_accounts.py @@ -0,0 +1,182 @@ +"""Plan creation/update/deletion of integration service accounts. + +The write body is three keys: ``name``, ``api_name`` and +``permission_groups``. Kizen derives ``api_name`` from ``name`` on create and +on every PATCH that sends ``name``, so the update planner sends only what +changed and pins the current ``api_name`` through a rename. Automation and +plugin accounts are Kizen's own and are refused. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api import permissions as perm_api +from kizen_builder.api import service_accounts as sa_api +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools.plans import Plan, PlanError, PlanOperation, fetch_existing +from kizen_builder.tools.service_accounts import INTEGRATION +from kizen_builder.utils import derived_object_api_name + +_NO_GROUPS_NOTE = ( + "no permission groups — Kizen creates the account disabled until it has " + "at least one." +) + + +def _client() -> KizenClient: + return KizenClient(load_env_config()) + + +def _group_names(c: KizenClient) -> dict[str, str]: + return { + g["id"]: g.get("name") or g["id"] for g in perm_api.list_permission_groups(c) + } + + +def _check_groups(group_ids: list[str], groups: dict[str, str]) -> None: + unknown = [g for g in group_ids if g not in groups] + if unknown: + raise PlanError( + f"permission group id(s) not found: {unknown}. Available: {list(groups)}" + ) + + +def _require_integration(account: dict[str, Any]) -> None: + kind = account.get("account_type") + if kind != INTEGRATION: + raise PlanError( + f"'{account.get('name')}' is a {kind} account, which Kizen or a plugin " + "manages. Only integration service accounts can be changed from the CLI." + ) + + +def _fetch(c: KizenClient, account_id: str) -> dict[str, Any]: + return fetch_existing( + "service account", + account_id, + lambda: sa_api.get_service_account(c, account_id), + ) + + +def plan_create_service_account(name: str, group_ids: list[str] | None = None) -> Plan: + env = load_env_config().name + group_ids = list(group_ids or []) + predicted = derived_object_api_name(name) + with _client() as c: + accounts = sa_api.list_service_accounts(c) + groups = _group_names(c) + + same_name = next( + (a for a in accounts if (a.get("name") or "").lower() == name.lower()), None + ) + if same_name is not None: + raise PlanError( + f"service account '{name}' already exists (id {same_name['id']})." + ) + same_api = next((a for a in accounts if a.get("api_name") == predicted), None) + if same_api is not None: + raise PlanError( + f"api_name '{predicted}' (derived from the name) is already used by " + f"service account '{same_api.get('name')}'." + ) + _check_groups(group_ids, groups) + + preview: dict[str, Any] = { + "env": env, + "name": name, + "predicted api_name": predicted, + "permission_groups": [groups[g] for g in group_ids], + } + if not group_ids: + preview["note"] = _NO_GROUPS_NOTE + op = PlanOperation( + action="create", + kind="service_account", + key=name, + preview=preview, + payload={"name": name, "permission_groups": group_ids}, + ) + return Plan.build( + env=env, summary=f"Create service account '{name}'", operations=[op] + ) + + +def plan_update_service_account(account_id: str, changes: dict[str, Any]) -> Plan: + """``changes`` may carry ``name``, ``api_name`` and ``permission_groups`` + (ids; the list replaces the account's set).""" + env = load_env_config().name + with _client() as c: + current = _fetch(c, account_id) + _require_integration(current) + groups = _group_names(c) if "permission_groups" in changes else {} + + current_groups = [g["id"] for g in current.get("permission_groups") or []] + payload: dict[str, Any] = {} + diff: dict[str, Any] = {} + for field in ("name", "api_name"): + if field in changes and changes[field] != current.get(field): + payload[field] = changes[field] + diff[field] = (current.get(field), changes[field]) + if "permission_groups" in changes: + wanted = list(dict.fromkeys(changes["permission_groups"])) + _check_groups(wanted, groups) + if set(wanted) != set(current_groups): + payload["permission_groups"] = wanted + diff["permission_groups"] = ( + sorted(groups.get(g, g) for g in current_groups), + sorted(groups[g] for g in wanted), + ) + if "name" in payload and "api_name" not in payload: + # Without it, Kizen re-derives api_name from the new name. + payload["api_name"] = current.get("api_name") + + name = current.get("name") or account_id + preview: dict[str, Any] = { + "env": env, + "service account": name, + "diff": {k: f"{v[0]} → {v[1]}" for k, v in diff.items()} or "no changes", + } + if payload.get("permission_groups") == []: + preview["note"] = "no permission groups — Kizen disables the account." + op = PlanOperation( + action="update" if payload else "skip", + kind="service_account", + key=name, + preview=preview, + payload=payload, + existing_uuid=account_id, + ) + summary = ( + f"Update service account '{name}' ({len(diff)} change(s))" + if diff + else f"No changes to service account '{name}'" + ) + return Plan.build(env=env, summary=summary, operations=[op]) + + +def plan_delete_service_account(account_id: str) -> Plan: + env = load_env_config().name + with _client() as c: + current = _fetch(c, account_id) + _require_integration(current) + name = current.get("name") or account_id + op = PlanOperation( + action="delete", + kind="service_account", + key=name, + preview={ + "env": env, + "service account": name, + "id": account_id, + "note": ( + "Kizen archives the account; it disappears from every read and " + "can't be restored through the API." + ), + }, + existing_uuid=account_id, + ) + return Plan.build( + env=env, summary=f"Delete service account '{name}'", operations=[op] + ) diff --git a/src/kizen_builder/tools/plans.py b/src/kizen_builder/tools/plans.py index dbddba2..57fd8da 100644 --- a/src/kizen_builder/tools/plans.py +++ b/src/kizen_builder/tools/plans.py @@ -38,6 +38,7 @@ from kizen_builder.api import pipelines as pipelines_api from kizen_builder.api import records as records_api from kizen_builder.api import saved_views as sv_api +from kizen_builder.api import service_accounts as sa_api from kizen_builder.api import team as team_api from kizen_builder.api.client import KizenAPIError, KizenClient from kizen_builder.config import EnvConfig, load_env_config @@ -102,6 +103,7 @@ def copy_name_preview(source_name: str | None, name: str | None) -> str: "role", "team_member", "permission_group", + "service_account", "permission_setting", "filter_group", "quick_filter", @@ -416,6 +418,18 @@ def apply_plan(plan: Plan) -> ApplyResult: message = f"api_name {resp['name']}" if resp["name"] != op.key: message += f" (the plan predicted {op.key})" + elif ( + op.kind == "service_account" + and op.action == "create" + and isinstance(resp, dict) + ): + message = ( + f"api_name {resp.get('api_name')}; Kizen issued an API key, " + "which the CLI does not show" + ) + predicted = op.preview.get("predicted api_name") + if predicted and resp.get("api_name") != predicted: + message += f" (the plan predicted {predicted})" elif op.kind == "record_archive" and isinstance(resp, dict): # Counts ids sent, not records archived (confirmed live # 2026-09-28: an already-archived id still counts). @@ -1019,6 +1033,18 @@ def patch_and_read(copy_id: str) -> dict[str, Any]: return {} return perm_api.update_role(client, op.existing_uuid, op.payload) + if op.kind == "service_account": + if op.action == "create": + return sa_api.create_service_account(client, op.payload) + if op.existing_uuid is None: + raise PlanError( + f"{op.action} service_account op '{op.key}' has no existing_uuid — planning bug" + ) + if op.action == "delete": + sa_api.delete_service_account(client, op.existing_uuid) + return {} + return sa_api.update_service_account(client, op.existing_uuid, op.payload) + if op.kind == "team_member": team_api.change_team_member_roles( client, diff --git a/src/kizen_builder/tools/service_accounts.py b/src/kizen_builder/tools/service_accounts.py new file mode 100644 index 0000000..f0e894e --- /dev/null +++ b/src/kizen_builder/tools/service_accounts.py @@ -0,0 +1,45 @@ +"""Read tools for service accounts. + +Config-resolving wrappers over ``kizen_builder.api.service_accounts``. +Mutations live in the planners. No read here returns a key: the list and +detail rows carry only Kizen's own preview. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api import service_accounts as sa_api +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools.permissions import resolve_by_name +from kizen_builder.utils import is_uuid + +INTEGRATION = "service_integration" + + +def resolve_service_account(client: KizenClient, ref: str) -> dict[str, Any]: + """Resolve a UUID, an exact api_name or a name to one account row.""" + items = sa_api.list_service_accounts(client) + if not is_uuid(ref): + by_api_name = [i for i in items if i.get("api_name") == ref] + if len(by_api_name) == 1: + return by_api_name[0] + return resolve_by_name(ref, items, "service account") + + +def list_service_accounts(search: str | None = None) -> list[dict[str, Any]]: + with KizenClient(load_env_config()) as client: + return sa_api.list_service_accounts(client, search=search) + + +def get_service_account(ref: str) -> dict[str, Any]: + with KizenClient(load_env_config()) as client: + account = resolve_service_account(client, ref) + return sa_api.get_service_account(client, account["id"]) + + +def list_service_account_history(ref: str, limit: int = 25) -> list[dict[str, Any]]: + with KizenClient(load_env_config()) as client: + account = resolve_service_account(client, ref) + return sa_api.list_service_account_history(client, account["id"], limit) diff --git a/tests/drift/contracts.py b/tests/drift/contracts.py index 45a02d4..f4a2f5e 100644 --- a/tests/drift/contracts.py +++ b/tests/drift/contracts.py @@ -172,6 +172,21 @@ def key(self) -> str: Contract("permissions", "create role", "post", "/api/role"), Contract("permissions", "update role", "patch", "/api/role/{id}"), Contract("permissions", "delete role", "delete", "/api/role/{id}"), + Contract( + "service_accounts", "create service account", "post", "/api/service-accounts" + ), + Contract( + "service_accounts", + "update service account", + "patch", + "/api/service-accounts/{id}", + ), + Contract( + "service_accounts", + "delete service account", + "delete", + "/api/service-accounts/{id}", + ), # --- records ------------------------------------------------------- Contract( "records", "create record", "post", "/api/records/{object_identifier}/add" diff --git a/tests/drift/schema_snapshot.json b/tests/drift/schema_snapshot.json index df782a0..2dafa47 100644 --- a/tests/drift/schema_snapshot.json +++ b/tests/drift/schema_snapshot.json @@ -2,7 +2,7 @@ "_meta": { "base_url": "https://app.go.kizen.com", "captured": "2026-10-14", - "contracts_tracked": 69, + "contracts_tracked": 72, "how_to_refresh": "KIZEN_DRIFT_PROFILE= KIZEN_DRIFT_UPDATE_SNAPSHOT=1 uv run pytest -m drift", "info": { "title": "Kizen API", @@ -87,6 +87,19 @@ "schema_path": "/api/role/{id}", "surface": "permissions" }, + "DELETE /api/service-accounts/{id}": { + "label": "delete service account", + "present": true, + "query_params": [ + "ordering" + ], + "request_schema": null, + "responses": [ + "204" + ], + "schema_path": "/api/service-accounts/{id}", + "surface": "service_accounts" + }, "PATCH /api/custom-objects/{object_identifier}": { "label": "update custom object", "present": true, @@ -275,6 +288,30 @@ "schema_path": "/api/role/{id}", "surface": "permissions" }, + "PATCH /api/service-accounts/{id}": { + "label": "update service account", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "name": "string", + "permission_groups": "array[string]" + }, + "required": [], + "type": "object" + }, + "request_required": false, + "request_schema": "PatchedServiceAccountWriteRequest", + "responses": [ + "200" + ], + "schema_path": "/api/service-accounts/{id}", + "surface": "service_accounts" + }, "POST /api/automation2/automations": { "label": "create automation", "present": true, @@ -744,6 +781,30 @@ "schema_path": "/api/role", "surface": "permissions" }, + "POST /api/service-accounts": { + "label": "create service account", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "name": "string", + "permission_groups": "array[string]" + }, + "required": [], + "type": "object" + }, + "request_required": false, + "request_schema": "ServiceAccountWriteRequest", + "responses": [ + "201" + ], + "schema_path": "/api/service-accounts", + "surface": "service_accounts" + }, "PUT /api/automation2/automations/{automation_identifier}": { "label": "replace automation", "present": true, diff --git a/tests/test_execute_dispatch.py b/tests/test_execute_dispatch.py index 6f3210b..22b1f9a 100644 --- a/tests/test_execute_dispatch.py +++ b/tests/test_execute_dispatch.py @@ -397,6 +397,12 @@ def _saved_view(kind: str, base: str, served: set[str]) -> list[Any]: *_form_like("form", "/api/forms"), *_form_like("survey", "/api/surveys"), *_crud("role", "/api/role", f"/api/role/{E}", parent=False), + *_crud( + "service_account", + "/api/service-accounts", + f"/api/service-accounts/{E}", + parent=False, + ), _row( "team_member", "update", diff --git a/tests/test_service_accounts.py b/tests/test_service_accounts.py new file mode 100644 index 0000000..70fc374 --- /dev/null +++ b/tests/test_service_accounts.py @@ -0,0 +1,500 @@ +"""`kizen service-accounts`: the API wrappers, the three planners, the CLI, +and the guarantee that a create response's API key reaches no output. + +Planners build their own `KizenClient` from config, so these go through +`respx` against `FAKE_BASE_URL`, as `test_permission_plans.py` does. +""" + +from __future__ import annotations + +import copy +import json + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +import kizen_builder.cli as cli +from kizen_builder.api import service_accounts as sa_api +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools import plans as plan_tools +from kizen_builder.tools import service_accounts as sa_tools +from kizen_builder.tools.planners.service_accounts import ( + plan_create_service_account, + plan_delete_service_account, + plan_update_service_account, +) +from kizen_builder.tools.plans import PlanError +from tests.conftest import FAKE_BASE_URL + +runner = CliRunner() + +SA_URL = f"{FAKE_BASE_URL}/api/service-accounts" +GROUPS_URL = f"{FAKE_BASE_URL}/api/permission-group" +SENTINEL = "SENTINEL-KEY-DO-NOT-PRINT" + +ACCOUNT_ID = "00000000-0000-4000-8000-0000000005a1" +AUTOMATION_ID = "00000000-0000-4000-8000-0000000005a2" +PLUGIN_ID = "00000000-0000-4000-8000-0000000005a3" +NEW_ID = "00000000-0000-4000-8000-0000000005a4" +GROUP_ID = "00000000-0000-4000-8000-000000000201" +GROUP_ID_2 = "00000000-0000-4000-8000-000000000202" +UNKNOWN_GROUP_ID = "00000000-0000-4000-8000-0000000009ff" + +GROUPS = [ + {"id": GROUP_ID, "name": "Integrations"}, + {"id": GROUP_ID_2, "name": "Reporting"}, +] + + +def _account(id_, name, api_name, account_type="service_integration", groups=()): + return { + "id": id_, + "name": name, + "api_name": api_name, + "account_type": account_type, + "status": "active" if groups else "disabled", + "email": f"{api_name}@serviceaccounts.example.com", + "permission_groups": [g for g in GROUPS if g["id"] in groups], + "api_token_count": 1, + "primary_api_token_preview": "abc" + "*" * 33, + "secondary_api_token_preview": None, + "created": "2026-10-27T12:00:00Z", + "updated": "2026-10-27T12:00:00Z", + "last_login": None, + } + + +INTEGRATION = _account(ACCOUNT_ID, "Example Bot", "example_bot", groups=(GROUP_ID,)) +AUTOMATION = _account(AUTOMATION_ID, "Workflow Code", None, "service_automation") +PLUGIN = _account( + PLUGIN_ID, "Plugin Service", "plugin__service", "service_automationplugin" +) +ACCOUNTS = [INTEGRATION, AUTOMATION, PLUGIN] + + +def _envelope(results): + return {"count": len(results), "next": None, "previous": None, "results": results} + + +def _mock_reads(accounts=ACCOUNTS): + respx.get(SA_URL).mock(return_value=httpx.Response(200, json=_envelope(accounts))) + respx.get(GROUPS_URL).mock(return_value=httpx.Response(200, json=_envelope(GROUPS))) + for a in accounts: + respx.get(f"{SA_URL}/{a['id']}").mock(return_value=httpx.Response(200, json=a)) + + +def _created(name, api_name, groups=()): + body = _account(NEW_ID, name, api_name, groups=groups) + body["api_key"] = { + "id": "00000000-0000-4000-8000-0000000005b1", + "key": SENTINEL, + "key_preview": "SEN" + "*" * 33, + "employee": {"id": NEW_ID, "display_name": name}, + } + return body + + +def _client(): + return KizenClient(load_env_config()) + + +# --------------------------------------------------------------------------- +# api +# --------------------------------------------------------------------------- + + +@respx.mock +def test_list_pages_at_100_and_sends_search(): + route = respx.get(SA_URL).mock( + return_value=httpx.Response(200, json=_envelope([INTEGRATION])) + ) + with _client() as c: + assert sa_api.list_service_accounts(c, search="Example") == [INTEGRATION] + assert dict(route.calls[0].request.url.params) == { + "page_size": "100", + "search": "Example", + } + + +@respx.mock +def test_history_makes_one_request_with_page_size_limit(): + route = respx.get(f"{SA_URL}/{ACCOUNT_ID}/history").mock( + return_value=httpx.Response( + 200, + json={**_envelope([{"id": "e1"}]), "next": f"{SA_URL}/x?page=2"}, + ) + ) + with _client() as c: + assert sa_api.list_service_account_history(c, ACCOUNT_ID, 5) == [{"id": "e1"}] + assert route.call_count == 1 + assert dict(route.calls[0].request.url.params) == {"page_size": "5"} + + +@respx.mock +def test_create_strips_the_api_key_from_its_return(): + respx.post(SA_URL).mock( + return_value=httpx.Response(201, json=_created("New Bot", "new_bot")) + ) + with _client() as c: + resp = sa_api.create_service_account(c, {"name": "New Bot"}) + assert "api_key" not in resp + assert SENTINEL not in json.dumps(resp) + + +def test_no_function_calls_an_api_tokens_endpoint(): + import inspect + + functions = inspect.getmembers(sa_api, inspect.isfunction) + own = [f for _, f in functions if f.__module__ == sa_api.__name__] + assert len(own) == 6 + assert not [f.__name__ for f in own if "api-tokens" in inspect.getsource(f)] + + +# --------------------------------------------------------------------------- +# resolve +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "ref", [ACCOUNT_ID, "example_bot", "Example Bot", "example bot"] +) +@respx.mock +def test_resolve_by_uuid_api_name_or_name(ref): + _mock_reads() + with _client() as c: + assert sa_tools.resolve_service_account(c, ref)["id"] == ACCOUNT_ID + + +@respx.mock +def test_resolve_unknown_raises_lookup_error_naming_service_account(): + _mock_reads() + with _client() as c, pytest.raises(LookupError, match="service account 'nope'"): + sa_tools.resolve_service_account(c, "nope") + + +# --------------------------------------------------------------------------- +# plan_create_service_account +# --------------------------------------------------------------------------- + + +@respx.mock +def test_create_plans_name_and_groups_with_predicted_api_name(): + _mock_reads() + plan = plan_create_service_account("New Bot (MCP)!", [GROUP_ID]) + (op,) = plan.operations + assert (op.action, op.kind) == ("create", "service_account") + assert op.payload == {"name": "New Bot (MCP)!", "permission_groups": [GROUP_ID]} + assert op.preview["predicted api_name"] == "new_bot_mcp" + assert op.preview["permission_groups"] == ["Integrations"] + assert "note" not in op.preview + + +@respx.mock +def test_create_without_groups_notes_the_account_starts_disabled(): + _mock_reads() + (op,) = plan_create_service_account("New Bot").operations + assert op.payload == {"name": "New Bot", "permission_groups": []} + assert op.preview["note"] == ( + "no permission groups — Kizen creates the account disabled until it has " + "at least one." + ) + + +@respx.mock +def test_create_refuses_a_duplicate_name_case_insensitively(): + _mock_reads() + with pytest.raises(PlanError) as exc: + plan_create_service_account("EXAMPLE BOT") + assert str(exc.value) == ( + f"service account 'EXAMPLE BOT' already exists (id {ACCOUNT_ID})." + ) + + +@respx.mock +def test_create_refuses_a_taken_derived_api_name(): + _mock_reads() + with pytest.raises(PlanError) as exc: + plan_create_service_account("Example-Bot") + assert str(exc.value) == ( + "api_name 'example_bot' (derived from the name) is already used by " + "service account 'Example Bot'." + ) + + +@respx.mock +def test_create_refuses_an_unknown_group(): + _mock_reads() + with pytest.raises(PlanError, match="permission group id"): + plan_create_service_account("New Bot", [UNKNOWN_GROUP_ID]) + + +# --------------------------------------------------------------------------- +# plan_update_service_account +# --------------------------------------------------------------------------- + + +@respx.mock +def test_update_with_the_same_group_set_skips(): + account = copy.deepcopy(INTEGRATION) + account["permission_groups"] = list(GROUPS) + _mock_reads([account]) + (op,) = plan_update_service_account( + ACCOUNT_ID, {"permission_groups": [GROUP_ID_2, GROUP_ID]} + ).operations + assert op.action == "skip" + assert op.payload == {} + + +@respx.mock +def test_rename_keeps_the_current_api_name(): + _mock_reads() + (op,) = plan_update_service_account(ACCOUNT_ID, {"name": "Renamed Bot"}).operations + assert op.action == "update" + assert op.payload == {"name": "Renamed Bot", "api_name": "example_bot"} + + +@respx.mock +def test_update_sends_only_the_keys_that_changed(): + _mock_reads() + (op,) = plan_update_service_account( + ACCOUNT_ID, + { + "name": "Example Bot", + "api_name": "example_bot_v2", + "permission_groups": [GROUP_ID_2], + }, + ).operations + assert op.payload == { + "api_name": "example_bot_v2", + "permission_groups": [GROUP_ID_2], + } + assert op.preview["diff"]["permission_groups"] == "['Integrations'] → ['Reporting']" + + +@respx.mock +def test_clearing_groups_is_allowed_and_notes_the_account_is_disabled(): + _mock_reads() + (op,) = plan_update_service_account( + ACCOUNT_ID, {"permission_groups": []} + ).operations + assert op.payload == {"permission_groups": []} + assert "disables the account" in op.preview["note"] + + +@respx.mock +def test_update_refuses_an_unknown_group(): + _mock_reads() + with pytest.raises(PlanError, match="permission group id"): + plan_update_service_account( + ACCOUNT_ID, {"permission_groups": [UNKNOWN_GROUP_ID]} + ) + + +@respx.mock +def test_update_of_a_missing_account_is_a_plan_error(): + respx.get(f"{SA_URL}/{NEW_ID}").mock( + return_value=httpx.Response(404, json={"detail": "Not found."}) + ) + with pytest.raises(PlanError, match="not found"): + plan_update_service_account(NEW_ID, {"name": "x"}) + + +@pytest.mark.parametrize( + ("account", "kind"), + [(AUTOMATION, "service_automation"), (PLUGIN, "service_automationplugin")], +) +@pytest.mark.parametrize("planner", ["update", "delete"]) +@respx.mock +def test_update_and_delete_refuse_accounts_kizen_or_a_plugin_manages( + account, kind, planner +): + _mock_reads() + with pytest.raises(PlanError) as exc: + if planner == "update": + plan_update_service_account(account["id"], {"name": "x"}) + else: + plan_delete_service_account(account["id"]) + assert str(exc.value) == ( + f"'{account['name']}' is a {kind} account, which Kizen or a plugin " + "manages. Only integration service accounts can be changed from the CLI." + ) + + +@respx.mock +def test_delete_plans_one_delete_with_the_archive_note(): + _mock_reads() + (op,) = plan_delete_service_account(ACCOUNT_ID).operations + assert (op.action, op.existing_uuid) == ("delete", ACCOUNT_ID) + assert op.preview["note"] == ( + "Kizen archives the account; it disappears from every read and can't be " + "restored through the API." + ) + + +# --------------------------------------------------------------------------- +# apply: the create result message +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("returned", "suffix"), + [("new_bot", ""), ("new_bot_2", " (the plan predicted new_bot)")], +) +@respx.mock +def test_create_result_message_reports_api_name_and_never_the_key(returned, suffix): + _mock_reads() + respx.post(SA_URL).mock( + return_value=httpx.Response(201, json=_created("New Bot", returned)) + ) + plan = plan_create_service_account("New Bot") + result = plan_tools.apply_plan(plan) + (r,) = result.results + assert r.status == "ok" and r.server_uuid == NEW_ID + assert r.message == ( + f"api_name {returned}; Kizen issued an API key, which the CLI does not show" + + suffix + ) + assert SENTINEL not in plan_tools.result_to_json(result) + assert SENTINEL not in plan_tools.plan_to_json(plan) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("extra", [["--json"], []]) +@respx.mock +def test_cli_create_never_prints_the_api_key(extra): + _mock_reads() + post = respx.post(SA_URL).mock( + return_value=httpx.Response(201, json=_created("New Bot", "new_bot")) + ) + result = runner.invoke( + cli.app, ["service-accounts", "create", "--name", "New Bot", "--yes", *extra] + ) + assert result.exit_code == 0, result.output + assert post.called + assert SENTINEL not in result.stdout + assert SENTINEL not in result.stderr + assert "Kizen issued an API key" in result.stdout + + +@respx.mock +def test_cli_create_resolves_groups_by_name(): + _mock_reads() + result = runner.invoke( + cli.app, + [ + "service-accounts", + "create", + "--name", + "New Bot", + "-g", + "Integrations", + "--dry-run", + "--json", + ], + ) + assert result.exit_code == 0, result.output + op = json.loads(result.stdout)["operations"][0] + assert op["payload"] == {"name": "New Bot", "permission_groups": [GROUP_ID]} + + +@respx.mock +def test_cli_list_json_emits_the_api_rows(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "list", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == ACCOUNTS + + +@respx.mock +def test_cli_list_csv_names_groups(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "list", "-o", "csv"]) + assert result.exit_code == 0, result.output + assert "Example Bot,example_bot,service_integration,active,Integrations,1," in ( + result.stdout + ) + + +@respx.mock +def test_cli_get_by_api_name_shows_groups(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "get", "example_bot"]) + assert result.exit_code == 0, result.output + assert "Integrations" in result.stdout + assert "abc***" in result.stdout + + +@respx.mock +def test_cli_unknown_ref_ends_on_error_and_exits_1(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "delete", "nope", "--yes"]) + assert result.exit_code == 1 + assert result.stderr.startswith("error:") + + +def test_cli_update_refuses_group_with_clear_groups(): + result = runner.invoke( + cli.app, + ["service-accounts", "update", "x", "-g", "Integrations", "--clear-groups"], + ) + assert result.exit_code == 2 + assert "error: pass --group or --clear-groups, not both." in result.stderr + + +@respx.mock +def test_cli_update_clear_groups_plans_an_empty_set(): + _mock_reads() + result = runner.invoke( + cli.app, + [ + "service-accounts", + "update", + "Example Bot", + "--clear-groups", + "--dry-run", + "--json", + ], + ) + assert result.exit_code == 0, result.output + op = json.loads(result.stdout)["operations"][0] + assert op["payload"] == {"permission_groups": []} + + +@respx.mock +def test_cli_history_table_and_empty(): + _mock_reads() + event = { + "id": "e1", + "created": "2026-10-27T12:00:00Z", + "type_name": "employee_created", + "type_display_name": "Team Member Invited", + "initiator": { + "email": "admin@example.com", + "first_name": "A", + "last_name": "B", + }, + } + route = respx.get(f"{SA_URL}/{ACCOUNT_ID}/history").mock( + side_effect=[ + httpx.Response(200, json=_envelope([event])), + httpx.Response(200, json=_envelope([])), + ] + ) + result = runner.invoke( + cli.app, ["service-accounts", "history", "Example Bot", "-n", "3"] + ) + assert result.exit_code == 0, result.output + assert "Team Member Invited" in result.stdout + assert "admin@example.com" in result.stdout + assert dict(route.calls[0].request.url.params) == {"page_size": "3"} + + result = runner.invoke(cli.app, ["service-accounts", "history", "Example Bot"]) + assert result.exit_code == 0, result.output + assert result.stdout.strip() == "No history."