From 66fdf287592941120b6b2ed542b0ab95dabaf944 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:16:24 -0400 Subject: [PATCH] Add a kizen service-accounts command group A Call LLM step runs its MCP tools as a service account, and builds had no way to make one without clicking through Settings. The new group lists, creates, updates, deletes and shows the history of integration accounts, with permission groups by name as roles takes them. Kizen returns the new account's API key in full on create, under an api_key object the schema doesn't declare. The API wrapper drops it before anything else sees the response, so no plan, result, table or --json output can contain it; reads show only Kizen's own preview. A rename sends the current api_name with the new name, because Kizen re-derives it from any PATCH that includes name. Update and delete refuse automation and plugin accounts, which Kizen or a plugin manages, and create refuses a duplicate name, which Kizen answers with a 500. --- CHANGELOG.md | 3 + CONTRIBUTING.md | 4 + scripts/cli-tree-baseline.txt | 143 +++++ src/kizen_builder/api/service_accounts.py | 78 +++ src/kizen_builder/cli/__init__.py | 1 + src/kizen_builder/cli/service_accounts.py | 286 ++++++++++ src/kizen_builder/docs/commands.md | 7 + src/kizen_builder/docs/reference.md | 1 + src/kizen_builder/docs/specs/README.md | 1 + .../docs/specs/service-account.md | 112 ++++ src/kizen_builder/docs/specs/team.md | 3 + src/kizen_builder/tools/permissions.py | 8 +- .../tools/planners/service_accounts.py | 182 +++++++ src/kizen_builder/tools/plans.py | 26 + src/kizen_builder/tools/service_accounts.py | 45 ++ tests/drift/contracts.py | 15 + tests/drift/schema_snapshot.json | 63 ++- tests/test_execute_dispatch.py | 6 + tests/test_service_accounts.py | 500 ++++++++++++++++++ 19 files changed, 1479 insertions(+), 5 deletions(-) create mode 100644 src/kizen_builder/api/service_accounts.py create mode 100644 src/kizen_builder/cli/service_accounts.py create mode 100644 src/kizen_builder/docs/specs/service-account.md create mode 100644 src/kizen_builder/tools/planners/service_accounts.py create mode 100644 src/kizen_builder/tools/service_accounts.py create mode 100644 tests/test_service_accounts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..fb65167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,9 @@ called out explicitly under **Changed** or **Removed**. ### 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. - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab20d8..98a7afb 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..1fe8f20 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 @@ -4901,6 +5023,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."