diff --git a/CLAUDE.md b/CLAUDE.md index 3050fb52..3e6848ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1264,6 +1264,60 @@ kbagent semantic-layer reference-data delete --project P --id ID [--yes] # 0.97.0)` cannot be written on these `# ` comment lines (check_version_gates.py parses them as # ATX headings). +kbagent rls list --project P +kbagent rls detail --project P --policy-id ID +kbagent rls schema --project P +kbagent rls create --project P --table-id TABLE_ID --rules JSON|@file|- [--default JSON|@file|-] [--dialect snowflake|bigquery] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--dry-run] [--yes] +kbagent rls update --project P --policy-id ID [--table-id TABLE_ID] [--dialect snowflake|bigquery] [--rules JSON|@file|-] [--default JSON|@file|-] [--target-project ALIAS|ID ... | --clear-target-projects] [--dry-run] [--yes] +kbagent rls delete --project P --policy-id ID [--dry-run] [--yes] +kbagent rls setup --project P [--dialect snowflake|bigquery] [--rules JSON|@file|-] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--yes] +# rls: row-level security policy authoring, metastore-backed (`rls-policy` object type, +# one object per protected table, named by it). SCOPE: `targeted` by default (the schema's own +# default: the owning project plus --target-project grants); `--scope organization` governs the +# table in EVERY project of the org (the enforcement applies every listed policy) and is +# destructive-class (FLAG_ESCALATIONS); there is no project scope. WHO: a project admin may +# write `targeted` policies of its own project without grants; grants and organization scope +# need the organization-admin role (403 otherwise). Enforced only where the project has the +# `row-level-security` feature. Policy schema 1.1.0 (the metastore default): a rule selects +# identities by principal, principals or IdP `groups`; within a policy every rule matching one +# identity applies and the conditions combine with OR; several policies on one table combine +# with AND (an added policy only narrows); a reader with no identity is always refused; +# the wildcard `"*"` (sole entry of `principal`/`principals`/`groups`) matches every identified reader and is how a table is opened to everyone under default deny; in a governed workspace +# (the platform denies by default, admins never exempt) a table with no rls-policy is refused; +# `--default` is the condition for an identified reader no rule matches (absent = refused; +# {"false": true} = no rows; to remove a default, recreate the policy). Needs an MCP engine +# that reads 1.1.0 (keboola/mcp-server#709). Writes refuse a --dialect other than the project backend (it defaults to the +# backend; the enforcement would refuse every read of that table). --rules is a JSON array of +# {principal|principals|groups, condition}; condition is a declarative primitive tree +# (column/op/value comparisons, in/not_in, is_null/is_not_null, and/or nesting, {"true": true} +# or {"false": true}) -- never a free-text predicate; a null comparison is refused (use +# is_null); `value: {"$identity": "email"}` / `values: {"$identity": "groups"}` are resolved +# per reader by the enforcement. `update` reads the policy, validates the MERGED result, then PATCHes +# only the changed keys (grants first); the result is re-read. `delete` is destructive-class. +# `--dry-run` previews with columns quoted like the enforcement (keboola-mcp-server +# `query_data`), but it is not the enforcement. `rls setup` is interactive-terminal-only +# (under --json / no TTY: INVALID_ARGUMENT, exit 2) and has no REST route. REST: POST +# /rls/{project}, PATCH /rls/{project}/{policy_id}, DELETE ...?dry_run=; body field +# `target_projects` (alias or ID). A stack whose metastore predates the schemas answers +# NOT_FOUND. New error code: INVALID_RLS_POLICY. Version gate for this whole entry lives in +# gotchas.md -- a `(since vNEXT)` tag cannot be written on these `# ` comment lines +# (check_version_gates.py parses them as ATX markdown headings). + +kbagent cls list --project P +kbagent cls detail --project P --policy-id ID +kbagent cls schema --project P +kbagent cls create --project P --table-id TABLE_ID --rules JSON|@file|- [--dialect snowflake|bigquery] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--dry-run] [--yes] +kbagent cls update --project P --policy-id ID [--table-id TABLE_ID] [--dialect snowflake|bigquery] [--rules JSON|@file|-] [--target-project ALIAS|ID ... | --clear-target-projects] [--dry-run] [--yes] +kbagent cls delete --project P --policy-id ID [--dry-run] [--yes] +# cls: column-level security policy authoring (`cls-policy` object type, one object per +# protected table), sibling of `rls`: same scope default, write permissions, dialect check, +# partial update and permission classes. --rules is a JSON array of +# {principal|principals|groups, visible_columns: [col, ...]} objects -- an allowlist projection +# (masking is not supported); within a policy an identity several rules match sees the union +# of their columns, several policies intersect; no `--default`. `--dry-run` prints each selector's projection. No `setup` +# wizard; the REST routes under /cls/{project} mirror /rls. +# New error code: INVALID_CLS_POLICY. Version gate lives in gotchas.md. + kbagent http get PATH [--timeout SECONDS] kbagent http post PATH [--body JSON|@file|-] [--timeout SECONDS] kbagent http patch PATH [--body JSON|@file|-] [--timeout SECONDS] diff --git a/docs/error-codes.md b/docs/error-codes.md index f37d951b..3af3e6a0 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -195,3 +195,10 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. |---|---| | `MR_NOT_READY_TO_MERGE` | Merge answered 409 with `storage.mergeRequests.notReadyToMerge`: a project merge lock is held, the MR is in a state that cannot merge, or another MR in the project is already processing. Transient -- retryable | | `MR_MERGE_CONFLICT` | Merge answered 409 with `storage.mergeRequests.validation` (or, on older stacks, no code): configurations changed on both branches; `details.api_error_params.errors` lists them. Not retryable -- resolve the conflicts and merge again | + +### Row-Level and Column-Level Security + +| Code | Description | +|---|---| +| `INVALID_CLS_POLICY` | A `cls create`/`update` candidate body (`table`/`dialect`/`rules`/`visible_columns`) failed validation -- either the schema-independent local checks (`principal`/`principals` not exactly one, empty or non-identifier `visible_columns`) or the live JSON-Schema check against the metastore's `cls-policy` schema when reachable. The message lists every violation found; nothing was written. Not retryable | +| `INVALID_RLS_POLICY` | An `rls create`/`update` candidate body (`table`/`dialect`/`rules`/`condition`) failed validation -- either the schema-independent local checks (unknown op, `principal`/`principals` not exactly one, a `"*"` wildcard that is not the sole entry of its list, or one used against a metastore whose `rls-policy` schema is older than 1.1.0) or the live JSON-Schema check against the metastore's `rls-policy` schema when reachable. The message lists every violation found; nothing was written. Not retryable | diff --git a/docs/web-server-endpoints.md b/docs/web-server-endpoints.md index 2cd3e525..2b269a23 100644 --- a/docs/web-server-endpoints.md +++ b/docs/web-server-endpoints.md @@ -9,7 +9,7 @@ auth, and the concepts behind these routes live in [`web-server.md`](web-server.md); a running server serves the same spec interactively at `/docs` (Swagger) and `/openapi.json`. -**257 operations** across **222 paths** and **32 routers**. +**269 operations** across **228 paths** and **34 routers**. Paths are shown as the server registers them. Reaching them through the Node BFF (or single-process `--ui` mode) prefixes every path with `/api`. @@ -424,6 +424,32 @@ Model, validate, import/export, diff, promote, and build semantic layer artifact | `PUT` | `/semantic-layer/scope/{context_id}/elevation-request` | Request scope elevation | | `DELETE` | `/semantic-layer/scope/{context_id}/elevation-request` | Withdraw scope elevation request | +### `rls` (6 operations) + +Row-level security policies (metastore-backed `rls-policy` objects), authored at `targeted` scope unless `organization` is asked for -- never `project` scope. Mirrors `kbagent rls list|detail|schema|create|update|delete` (`rls setup`'s interactive picker has no endpoint of its own, same carve-out as `auth register-projects`). + +| Method | Path | Summary | +|---|---|---| +| `GET` | `/rls/{project}` | List RLS policies | +| `POST` | `/rls/{project}` | Create a RLS policy | +| `GET` | `/rls/{project}/schema` | Fetch the live rls-policy JSON Schema | +| `GET` | `/rls/{project}/{policy_id}` | Get one RLS policy | +| `PATCH` | `/rls/{project}/{policy_id}` | Update a RLS policy | +| `DELETE` | `/rls/{project}/{policy_id}` | Delete a RLS policy | + +### `cls` (6 operations) + +Column-level security policies (metastore-backed `cls-policy` objects: a per-principal `visible_columns` allowlist), authored at `targeted` scope unless `organization` is asked for -- never `project` scope. Mirrors `kbagent cls list|detail|schema|create|update|delete`. + +| Method | Path | Summary | +|---|---|---| +| `GET` | `/cls/{project}` | List CLS policies | +| `POST` | `/cls/{project}` | Create a CLS policy | +| `GET` | `/cls/{project}/schema` | Fetch the live cls-policy JSON Schema | +| `GET` | `/cls/{project}/{policy_id}` | Get one CLS policy | +| `PATCH` | `/cls/{project}/{policy_id}` | Update a CLS policy | +| `DELETE` | `/cls/{project}/{policy_id}` | Delete a CLS policy | + ## AI & Tools ### `kai` (6 operations) diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index fe0b4587..5215f7e2 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -155,6 +155,7 @@ been retired, so its absence is NOT a promise (see §1 Rule 6). | Read a semantic-layer model (models, metrics, datasets, constraints) | `kbagent --json semantic-layer show --project P [--model M] [--type metric\|dataset\|relationship\|constraint\|glossary]`; `model list`; `search-context` / `get-context` for glob/id lookup; `validate [--deep]` before trusting one. Any valid, non-disabled, non-expired Storage token works for reads (0.97.0, PSGO-282 -- the metastore no longer requires a master token for GET/List); a stale `MISSING_MASTER_TOKEN` on a plain read means an outdated kbagent still carrying the old reclassification, not a real gate | -- | hand-rolled `httpx` loops against `metastore.*.keboola.com` (bypasses retry/backoff and the kbagent error envelope) | | ANY semantic-layer write (add / edit / remove / import / promote / build / scope add|remove|set|request-create) | `kbagent semantic-layer export` FIRST (the metastore has no soft-delete and no version history -- the snapshot is the only restore path), then the write, `--dry-run` where offered. Needs a token whose Storage Admin role is `admin` on the project (a master token qualifies, but so does any project-admin user token as of PSGO-282) -- a non-admin token still gets `MISSING_MASTER_TOKEN`/403 on writes, just not on reads. `Read` [semantic-layer-workflow.md](../skills/kbagent/references/semantic-layer-workflow.md) before starting: it carries the per-verb recipes, the rename cascade and the promote classification | `semantic-layer diff` (`--project-a/-b` or `--file-a/-b`) to confirm what a write would change | raw metastore REST (no rollback, no orphan scan, no modelUUID rewrite); a write with no export taken | | Share a semantic-layer object across projects, or make it org-wide | **ASK THE USER which project(s) before EVER passing `--scope organization\|targeted` (on `model create`/`add `) or running `scope set --scope organization`/`scope request-create` -- never guess or default to widening visibility.** Once the user names the target(s): `--scope targeted --target-project ALIAS\|ID` grants specific projects; `scope add\|remove --project P --type T --context-id ID --target-project ALIAS\|ID` adjusts an existing item's grants from the owning project (a merge), `scope set --target-project ...` / `--clear` replaces the list; org-wide needs `scope request-create` then an org-admin's `scope set --scope organization --yes` (preview with `--dry-run`) -- **irreversible, no downgrade endpoint, destructive-class**. `add ` without `--scope` inherits its model's scope. `scope get --type T --context-id ID` shows the current state first. See [metastore-scope-workflow.md](../skills/kbagent/references/metastore-scope-workflow.md) | `scope request-list --type T` to find items an org-admin should act on | passing `--scope organization\|targeted` or running `scope set --scope organization` without the user having named the target project(s) first; assuming `--scope organization` succeeds on a non-admin token (it 403s -- creating directly at organization scope needs the SAME organization-admin role as elevating); forgetting that `import`/`promote`/`build --model` into a shared model also share every new item (they take the model's scope unless `import`/`promote` get `--scope`); trying to elevate an item created before 0.97.0 (schema version 1.0.0: recreate it via export, delete, create, import) | +| Author or change a row-level (RLS) or column-level (CLS) security policy | `kbagent {rls,cls} create/update/delete --project P --table-id T ...` -- `targeted` scope by default (`--target-project` grants); `--scope organization` applies in EVERY org project (org-admin): only if asked; never `project` scope. Schema 1.1.0: rules select by `principal`/`principals`/`groups` (`"*"` = every identified reader), matching rules OR, several policies AND, no identity = refused; needs an MCP engine reading 1.1.0. Needs the `row-level-security` feature. Details: [rls-workflow.md](../skills/kbagent/references/rls-workflow.md) | `rls schema` / `cls schema --project P`; `--dry-run` first | a raw predicate as `condition`; headless `rls setup` (use `rls create`) | | User asks to "log in" / authenticate via browser / register a session's projects | ATTENDED session + a background shell: run `kbagent auth login --device-code --stack URL --register-projects` in a **BACKGROUND** shell (capture stdout+stderr, human mode -- `--json` puts the panel on stderr), relay the verification URL + user code to the user, then poll `kbagent --json auth status` (exit 0 = signed in, exit 3 = not yet). The human's part is approving in the browser, not typing the command. No background shell -> hand the plain `auth login --stack URL --register-projects` to the user's terminal. To register projects from an EXISTING session, `kbagent auth register-projects --all` or `--project-id ID` is non-interactive and agent-safe | -- | running `auth login` in a FOREGROUND tool shell (~120 s timeout kills it mid-flight); running it unattended (nobody can approve); re-running it blind without checking `auth status` first (orphans a session); the flagless `register-projects` picker unattended; reading the token out of `auth.json`; using a numeric project id as an alias | | User has NO Keboola account / project at all and asks to get started | `kbagent project create --url URL [--project ALIAS] [--name NAME] [--backend snowflake\|bigquery]` (0.95.0+) -- provisions a real project, stores its session and registers the alias in one call, agent-runnable. **Then relay the result's `confirm_url` to the human verbatim and say the project is owned by nobody until they open it**; after they confirm, the agent session is revoked by design -> `kbagent auth login --stack URL` | the user creating the project in the Keboola UI, then `auth login` / `project add --token-stdin` | calling it on a stack where a session already exists (exit 5 -- one session per stack); reporting success without the confirm link (an unclaimed project is a billable orphan); retrying it after a 5xx/429/503 without being asked (each success creates a new organization + project + credit grant) | | CI task has account credentials | `kbagent auth login-password --email E (--password-stdin \| --password P) [--totp-secret SEED]` (0.84.0+), agent-runnable | a static Storage token | `auth login` unattended | diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 640de3e7..bbc6072d 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -5,11 +5,11 @@ description: > Covers: searching configurations, job history, data lineage, dev branches, merge requests, workspace SQL debugging, GitOps sync, bucket sharing/linking, encrypting secrets, - Storage tables/files/snapshots, large (200 GB) file uploads, data apps, + Storage tables/files/snapshots, 200 GB file uploads, data apps, flows/schedules, invitations, feature flags, OTLP data streams, scoped Storage tokens, semantic - layer, Developer Portal, - first-time setup and logout. + layer, RLS/CLS, Developer Portal, + first-time setup, logout. Triggers: kbagent, Keboola, keboola config, keboola job, keboola lineage, keboola sync, gitops, dev branch, merge request, @@ -370,6 +370,19 @@ When working inside a git repository or project directory, run `kbagent init` (o | Flag a project-scoped item as awaiting an org-admin's step-up decision | `kbagent sl scope request-create --project PROJECT --type TYPE- --context-id CONTEXT-ID` | | Withdraw a pending scope-elevation request. | `kbagent sl scope request-delete --project PROJECT --type TYPE- --context-id CONTEXT-ID` | | List items of --type awaiting an org-admin's elevation decision, across the org | `kbagent sl scope request-list --project PROJECT --type TYPE-` | +| List row-level security policies visible to a project | `kbagent rls list --project PROJECT` | +| Show one RLS policy's full rule set | `kbagent rls detail --project PROJECT --policy-id POLICY-ID` | +| Print the live ``rls-policy`` JSON Schema fetched from the metastore | `kbagent rls schema --project PROJECT` | +| Create one RLS policy for one table (``targeted`` scope unless ``--scope organization``) | `kbagent rls create --project PROJECT --table-id TABLE-ID --rules RULES` | +| Update an existing RLS policy | `kbagent rls update --project PROJECT --policy-id POLICY-ID` | +| Delete an RLS policy -- its table's rows are no longer filtered | `kbagent rls delete --project PROJECT --policy-id POLICY-ID` | +| Guided RLS setup: pick tables, build a condition, preview, then write | `kbagent rls setup --project PROJECT` | +| List column-level security policies visible to a project | `kbagent cls list --project PROJECT` | +| Show one CLS policy's full rule set | `kbagent cls detail --project PROJECT --policy-id POLICY-ID` | +| Print the live ``cls-policy`` JSON Schema fetched from the metastore | `kbagent cls schema --project PROJECT` | +| Create one CLS policy for one table (``targeted`` scope unless ``--scope organization``) | `kbagent cls create --project PROJECT --table-id TABLE-ID --rules RULES` | +| Update an existing CLS policy | `kbagent cls update --project PROJECT --policy-id POLICY-ID` | +| Delete a CLS policy -- its table's columns become unrestricted | `kbagent cls delete --project PROJECT --policy-id POLICY-ID` | | GET an endpoint on the running kbagent serve | `kbagent http get ` | | POST to an endpoint on the running kbagent serve | `kbagent http post ` | | PATCH an endpoint on the running kbagent serve | `kbagent http patch ` | @@ -466,6 +479,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | SQL migration (input mapping removal) | [sql-migration-workflow](references/sql-migration-workflow.md) | | **Semantic layer (metastore)** -- models, metrics, datasets, constraints, glossary; validate / export / diff / promote / build / token | [semantic-layer-workflow](references/semantic-layer-workflow.md) | | **Share a semantic-layer item across projects / make it org-wide** -- `--scope`, `--target-project`, `scope get\|add\|remove\|set\|request-*` | [metastore-scope-workflow](references/metastore-scope-workflow.md) | +| **Row-level / column-level security (RLS, CLS)** -- author `rls-policy` / `cls-policy` objects, organization/targeted scope, guided RLS setup, condition primitives, column allowlists | [rls-workflow](references/rls-workflow.md) | | **Developer Portal** (identity CRUD, list/get apps, create/patch/upload-icon/publish/deprecate; TTY-confirm on writes) | [dev-portal-workflow](references/dev-portal-workflow.md) | | **Config metadata** (list/get/set/delete arbitrary key-value metadata on a configuration) | [config-metadata-workflow](references/config-metadata-workflow.md) | | **Storage descriptions** (describe bucket / table / column, batch from YAML) | [storage-describe-workflow](references/storage-describe-workflow.md) | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 3626c023..1ff5e78b 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -462,6 +462,29 @@ There is no bulk-elevate endpoint: promoting every semantic-layer object in an e - `semantic-layer reference-data set --project P [--model M] --dimension D --members-file PATH [--dataset-id T] [--description X]` -- create-or-replace. `--members-file` is a JSON array of member objects (`-` reads stdin). **Idempotent** on `dimension`: the lookup is project-wide (the envelope `name` = dimension is unique per project per type), so an existing record is replaced in place via `PUT` (the metastore increments `meta.revision`, preserving history) — like `edit` — regardless of which `--model` is passed (the resolved model is stored on the record); otherwise a new record is `POST`-ed. Response: `{id, dimension_name, member_count, action: "created"|"updated"}`. - `semantic-layer reference-data delete --project P --id ID [--yes]` -- delete by UUID (server-side soft-delete; the record stays in revision history). Non-TTY without `--yes` refuses with exit 2. +## Row-Level Security (RLS) + +Author `rls-policy` metastore objects -- one object per protected table, each holding a list of rules, each a selector (`principal`, `principals` or IdP `groups`; `"*"` as the sole entry matches every identified reader, *(since vNEXT)*) plus a condition primitive. Policy schema 1.1.0 *(since vNEXT)*: within a policy every rule matching one identity applies and the conditions combine with OR; several policies on one table combine with AND, so an added policy can only narrow access; a reader with no identity is always refused; an optional policy `default` covers an identified reader no rule matches (absent = refused). Needs an MCP engine that reads 1.1.0 (keboola/mcp-server#709). `kbagent` only **authors** these; the actual SQL rewrite/enforcement happens in `keboola-mcp-server`'s `query_data` tool (a different repo), only in projects with the `row-level-security` feature. **Scope is `targeted` unless `--scope organization`; never `project`.** `targeted` governs the owning project plus its `--target-project` grants; `organization` governs the table in every project of the organization (destructive-class flag). A project admin (master token) may write `targeted` policies of its own project without grants; grants and organization scope need the organization-admin role. Every write refuses a `dialect` other than the project backend (the enforcement would refuse every read of that table). A stack whose metastore predates the `rls-policy`/`cls-policy` schema migrations answers `rls schema`/`cls schema` with a classified `NOT_FOUND` -- see [rls-workflow.md](rls-workflow.md) Workflow 7. + +- `rls list --project P` -- list policies visible to (and applied in) a project. Output: `{project, policies: [{id, table, dialect, rule_count, scope, owner_project_id, source_project_id, target_project_ids}, ...]}`. `owner_project_id` is null for an `organization` policy; `target_project_ids` is shown to the owner only. +- `rls detail --project P --policy-id ID` -- full rule set for one policy: `{..., rules: [{principal|principals|groups, condition}], default?, revision}`. +- `rls schema --project P` -- live `rls-policy` JSON Schema fetched from the metastore. Unlike `flow schema`, there is no offline bundled snapshot -- always live-only, `NOT_FOUND` (exit 4) on fetch failure rather than a stale local copy. +- `rls create --project P --table-id TABLE_ID --rules JSON|@file|- [--default JSON|@file|-] [--dialect snowflake|bigquery] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--dry-run] [--yes]` -- create one policy for one table. `--rules` is a JSON array of `{principal|principals|groups, condition}` objects; `condition` is a declarative primitive tree (`column`/`op`/`value` comparisons `eq|ne|gt|gte|lt|lte`, `in`/`not_in` with `values`, `is_null`/`is_not_null`, `and`/`or` nesting of 2+ conditions, or the `{"true": true}` / `{"false": true}` sentinels) -- never a raw predicate string; a `null` comparison value is refused (use `is_null`); `value: {"$identity": "email"}` and `values: {"$identity": "groups"}` are resolved per reader by the enforcement. `--default` (a condition, e.g. `{"false": true}` = no rows) applies to identities no rule matches. `--dialect` defaults to the project backend and must equal it. `--target-project` (alias or ID, repeatable or comma-separated, `targeted` only) is sent in the create request itself. Validated locally always (rule and default shape, dialect), plus a live JSON-Schema check when the backend is reachable; failures raise `INVALID_RLS_POLICY` before any write; a policy already on that table is `ALREADY_EXISTS`. `--dry-run` returns the same compiled-condition preview (columns quoted per dialect; `default_preview` for a default) a real write would, without writing. +- `rls update --project P --policy-id ID [--table-id TABLE_ID] [--dialect snowflake|bigquery] [--rules JSON|@file|-] [--default JSON|@file|-] [--target-project ALIAS|ID ... | --clear-target-projects] [--dry-run] [--yes]` -- reads the policy, validates the MERGED result, then sends a partial `PATCH` of only the changed keys (unknown keys and concurrent changes to other keys survive). `--target-project` replaces the grants of a `targeted` policy, `--clear-target-projects` revokes them; grants change before the rules. The result is re-read after the write. A default cannot be removed by an update (recreate the policy). Nothing to change is `INVALID_ARGUMENT` (exit 2). +- `rls delete --project P --policy-id ID [--dry-run] [--yes]` -- delete a policy, un-protecting its table. Destructive-class (`--deny-destructive` blocks it). `--dry-run` shows the policy without deleting it. Confirmation prompt unless `--yes`; `--json` skips it too. +- `rls setup --project P [--dialect snowflake|bigquery] [--rules JSON|@file|-] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--yes]` -- guided, **interactive-terminal-only** wizard: checkbox table picker (reuses `storage tables`), then either an interactive column/op/value condition builder (a value is read as JSON when it is one: `42`, `true`) or the `--rules` escape hatch, a compiled-condition preview per table, a single confirm, then one `rls create` call per selected table. Under `--json` or without an interactive terminal it exits 2 with an `INVALID_ARGUMENT` error, and it has **no REST route** (`kbagent serve`), same carve-out as `auth register-projects`'s picker. + +## Column-Level Security (CLS) + +Author `cls-policy` metastore objects -- one object per protected table, each holding per-rule `visible_columns` allowlists (columns not listed are omitted from that identity's result; masking is not supported). Rules select by `principal`, `principals` or `groups`; an identity several rules match sees the union of their columns; there is no `default`. Sibling of RLS above: same scope default and options, write permissions, dialect check, partial `update` and `delete --dry-run`; no interactive `setup`. Enforcement (and RLS+CLS composition) happens in `keboola-mcp-server`'s `query_data`. See [rls-workflow.md](rls-workflow.md#column-level-security-cls). + +- `cls list --project P` -- list policies visible to a project (same row shape as `rls list`). +- `cls detail --project P --policy-id ID` -- full rule set: `{..., rules: [{principal|principals|groups, visible_columns}], revision}`. +- `cls schema --project P` -- live `cls-policy` JSON Schema from the metastore (`NOT_FOUND`, exit 4, when the stack's metastore does not have the object type). +- `cls create --project P --table-id TABLE_ID --rules JSON|@file|- [--dialect snowflake|bigquery] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--dry-run] [--yes]` -- `--rules` is a JSON array of `{principal|principals|groups, visible_columns: [col, ...]}` (column names `[A-Za-z0-9_]+`). `--dry-run` prints each selector's allowed projection without writing. Failures raise `INVALID_CLS_POLICY`. +- `cls update --project P --policy-id ID [--table-id TABLE_ID] [--dialect ...] [--rules JSON|@file|-] [--target-project ALIAS|ID ... | --clear-target-projects] [--dry-run] [--yes]` -- partial update, same as `rls update`. +- `cls delete --project P --policy-id ID [--dry-run] [--yes]` -- delete a policy; the table's columns become unrestricted for those principals. Destructive-class. + ## Self-Call HTTP (inside `kbagent serve` subprocesses; since v0.40.0) - `http get PATH [--timeout SECONDS]` -- GET an endpoint on the running `kbagent serve` - `http post PATH [--body JSON|@file|-] [--timeout SECONDS]` -- POST with optional JSON body diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 6f426f97..77b8a8fc 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -6333,3 +6333,92 @@ The package was named `keboola-agent-cli` up to 0.62.0 and is named `keboola-cli - **`col_map.in_table` matched by substring**, so a mapping could land on the wrong input edge. It must now be the full input table id, its `alias:`-qualified form, or a unique input-mapping destination; anything else is skipped and counted in `ai_col_map_skipped`. - **Stale AI results are not applied** *(since 0.99.1)*: a result whose `_code_hash` differs from the config's current code hash describes code that no longer exists; it is skipped entirely and listed in `ai_status.ai_results_stale` (a fresh task is emitted for it). A result without `_code_hash` is still applied. A `col_map` naming a table the config reads under two aliases now maps onto both edges instead of being skipped as ambiguous. - `ai_status` keys: `ai_edges_added` (reads + writes), `ai_reads_added`, `ai_writes_added`, `ai_columns_merged`, `ai_external_added`, `ai_col_map_skipped`, `ai_outputs_unresolved`, `ai_results_stale`, `ai_tasks_missing_code_file`. + +## Row-Level and Column-Level Security (RLS, CLS): targeted by default, never project scope + +*(since vNEXT)* + +`kbagent rls` authors metastore `rls-policy` objects -- one per protected table. It never +enforces anything itself: the actual SQL rewrite happens in `keboola-mcp-server`'s `query_data` +tool, a separate repo/runtime. See [rls-workflow.md](rls-workflow.md) for full recipes. + +- **Availability depends on the stack's metastore.** The `rls-policy` and `cls-policy` object types are + registered by the metastore's `2026-09-29` schema migrations. A stack whose metastore predates them + answers `rls schema`/`cls schema` with a classified `NOT_FOUND` (schema-fetch failure) -- expected + there, not a kbagent bug. Check with `kbagent --json rls schema --project P` / `cls schema`. +- **Scope is `targeted` unless you pass `--scope organization`; there is no `project` scope.** `targeted` + (the metastore schema's own default) governs the owning project plus the `--target-project` grants. + `organization` governs the table in EVERY project of the organization with the `row-level-security` + feature: the enforcement applies every policy the metastore lists for a project and does not re-check + the owner, so an organization policy is never "only where it matches". Every listed policy applies to + the listing project. `--scope organization` is destructive-class (`rls.create --scope organization` / + `cls.create --scope organization` in `FLAG_ESCALATIONS`). +- **Who may write.** A project admin (master token, admin role) may create/update/delete `targeted` + policies of its own project without grants. Grants (`--target-project`, `--clear-target-projects`) and + `--scope organization` need the organization-admin role -- a master token alone gets a 403 + (`ACCESS_DENIED`). A project admin reads only the policies its project owns. Policies are enforced only + where the consuming project has the `row-level-security` feature. +- **Policy schema 1.1.0 (the metastore default) is a superset of 1.0.0.** A rule selects identities by + `principal`, `principals` or IdP `groups` (exact strings, any listed group matches). Within a policy, + every rule matching one identity applies: RLS conditions combine with OR, CLS `visible_columns` are + united -- so one identity in several rules is no longer an error. Several policies on one table combine + with AND (CLS: intersection), so an added policy can only narrow access; a reader with no identity is + always refused. Needs a `keboola-mcp-server` whose engine reads schema 1.1.0 (keboola/mcp-server#709): an older engine refuses a policy that uses `groups`, `$identity`, a `default` or `{"false": true}`, and refuses every query of the project when one principal has two rules. RLS `--default` is the condition for + identities no rule matches (absent = refused; `{"false": true}` = no rows); an `update` cannot remove a + default (recreate the policy). `value: {"$identity": "email"}` and `values: {"$identity": "groups"}` are + resolved per reader by the enforcement. `dialect` is optional in the schema; kbagent still sends the + project backend and refuses another one (`INVALID_RLS_POLICY` / `INVALID_CLS_POLICY`), because the + enforcement refuses every read of a table whose policy names another dialect. An `organization` + policy also reaches projects on the OTHER backend -- prefer `targeted` in a mixed-backend organization. +- **Wildcard `"*"` and default deny (since vNEXT).** `principal: "*"`, `principals: ["*"]` or `groups: ["*"]` + (schema 1.1.0) matches every identified reader (never a reader with no identity); it must be the only entry + of its list (`INVALID_RLS_POLICY` / `INVALID_CLS_POLICY` otherwise) and is refused against a metastore + whose schema is older than 1.1.0, where `"*"` is a literal that matches nobody. In a governed workspace + the platform denies by default: a table with no `rls-policy` is refused (a `cls-policy` alone grants no + rows), admins included, so a table is opened to everyone only by an explicit wildcard policy with + `{"true": true}`. Workflow: `rls-workflow.md` > "Default deny and the wildcard". +- **One `rls-policy` object per protected table**, never one blob per project, named by its table: a + second policy on the same table in the same project answers `ALREADY_EXISTS` (update or delete the + first). `rls setup` creates one policy per selected table, all sharing the same rules. +- **`condition` is a fixed six-shape primitive vocabulary, never a free-text predicate.** + Comparison (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`), membership (`in`/`not_in` with `values`), nullness + (`is_null`/`is_not_null`), `and`/`or` (2+ nested conditions each, no `not`), or the `{"true": true}` / + `{"false": true}` sentinels. A `null` comparison value (or `null` in `values`) is refused: the + enforcement renders `col = NULL`, which matches nothing -- use `is_null`. A principal with whitespace or + control characters, or a group with control characters, is refused. +- **The `--dry-run` preview follows the enforcement's rendering but is not the enforcement.** Columns + are quoted per dialect (`"col"` / `` `col` ``, so matching is case-exact), booleans render + `TRUE`/`FALSE`, `$identity` placeholders `` / `()`, a default as + `default_preview`. The compiler that decides what a query returns is `keboola-mcp-server`'s + `rls.py::_compile_primitive` (sqlglot). +- **`rls update` sends a partial update (`PATCH`) of only the changed keys.** It still reads the policy + first: the metastore's PATCH validates only the keys it receives, so kbagent validates the MERGED + policy (and builds the `--dry-run` preview from it). Unknown keys and concurrent changes to other keys + survive. Grants change before the rules, so a refused grant writes nothing, and the result is re-read + after the write. An `update` with nothing to change is `INVALID_ARGUMENT` (exit 2). +- **`--target-project` takes an alias or a project ID**, repeatable or comma-separated, de-duplicated + (the metastore requires unique, positive IDs). An alias must be on the owner's stack. On `create` the + grants go in the create request itself (one transaction); on `update` the list replaces the grants of a + `targeted` policy and `--clear-target-projects` revokes them. An `organization` policy has no grants + and cannot be narrowed. +- **`delete` is destructive-class and has `--dry-run`.** `rls.delete` / `cls.delete` are `destructive` + (removing a policy removes the filter it enforced), so `--deny-destructive` blocks them. + `create`/`update`/`setup` stay `admin`; `cli:write` spans write, destructive AND admin. +- **`rls setup` is interactive-terminal-only and has no REST route.** Under `--json` or without an + interactive terminal it exits 2 with an `INVALID_ARGUMENT` error (a JSON envelope under `--json`) + pointing at `rls create`. A prompted value is read as JSON when it is one (`42`, `true`); quote it to + keep a string. Exits non-zero when any selected table's policy could not be created. +- **REST mirror.** `POST /rls/{project}` (`table_id`, `rules`, `default?`, `dialect?`, `scope`, + `target_projects`), `PATCH /rls/{project}/{policy_id}` (partial; `target_projects: []` revokes), `DELETE ...?dry_run=true`; + same for `/cls`. Every route is permission-gated; `scope: "organization"` is also checked as + `.create --scope organization`. +- **`cls` is the column-level sibling of `rls`.** `kbagent cls list|detail|schema|create|update|delete` + author `cls-policy` objects (`{table, dialect, rules: [{principal|principals|groups, visible_columns}]}`) + with the same scope, permissions, checks and partial `update` as `rls`. Differences: `visible_columns` is + a non-empty allowlist of `[A-Za-z0-9_]+` names (masking is not supported), `--dry-run` prints each + selector's projection, validation failures raise `INVALID_CLS_POLICY`, there is no `default`, and there + is no `cls setup`. +- **Reduced validation is reported.** If the live schema could not be fetched, writes still run every + local check but add a `warnings` entry; an authentication/permission failure while fetching the schema + is raised as that error. + diff --git a/plugins/kbagent/skills/kbagent/references/rls-workflow.md b/plugins/kbagent/skills/kbagent/references/rls-workflow.md new file mode 100644 index 00000000..b5a39580 --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/rls-workflow.md @@ -0,0 +1,426 @@ +# RLS / CLS Workflow -- Row- and Column-Level Security Policies + +Row-level security in Keboola is authored as metastore `rls-policy` objects +-- one object per protected table, holding a list of per-principal condition +primitives (never a hand-written SQL predicate). `kbagent` only **authors** +these objects; enforcement (the actual SQL rewrite on a query) happens +inside `keboola-mcp-server`'s `query_data` tool, a different repo and a +different runtime. `kbagent rls` never executes a query and never decides +whether a real query gets filtered -- it only creates, reads, updates and +deletes the policy objects the enforcement engine reads. + +**Scope: `targeted` by default, `organization` only when asked for, never `project`.** + +- `targeted` (the default, and the metastore schema's own default) governs the + table in the owning project (`--project`) plus every project granted with + `--target-project` (alias or project ID, repeatable or comma-separated). +- `--scope organization` governs the table in **every project of the + organization** that has the `row-level-security` project feature. The + enforcement applies every policy the metastore lists for a project -- it does + not re-check the owning project -- so an organization policy is never "only + for my project". It is gated as `destructive` (`rls.create --scope + organization` in `FLAG_ESCALATIONS`), so `--deny-destructive` blocks it. +- There is no `--scope project`: the schema does not support it for policies. + +**Who may write** (the metastore schema ACL, not kbagent): + +| Operation | Project admin (master token, admin role) | Organization admin | +|---|---|---| +| `create` / `update` / `delete` a `targeted` policy of its own project, no grants | yes | yes | +| `--target-project` (grants) on `create` or `update`, `--clear-target-projects` | no (403) | yes | +| `--scope organization` | no (403) | yes | +| Read (`list` / `detail`) | only the policies its project owns | everything visible to the project | + +**Enforcement prerequisite.** Policies do nothing until the consuming project has +the `row-level-security` project feature (it gates CLS too). Without it, queries +are unfiltered -- not an error. + +**How rules combine (policy schema 1.1.0, the metastore default; since vNEXT).** A +rule selects identities by `principal`, `principals` or IdP `groups`. Within a +policy, every rule that matches the reader applies: RLS conditions combine with +OR, CLS `visible_columns` are united. An RLS policy may set a `default` condition +for an identified reader no rule matches; without one, the policy refuses the +read. Several policies on one table combine with AND (CLS: intersection), so an +added policy can only narrow access, never widen it. A reader with no identity +is always refused. 1.1.0 is a superset of 1.0.0 -- every older policy keeps its +meaning. Needs a `keboola-mcp-server` whose engine reads schema 1.1.0 (keboola/mcp-server#709): an older engine refuses a policy that uses `groups`, `$identity`, a `default` or `{"false": true}`, and refuses every query of the project when one principal has two rules. + +**Dialect.** A policy whose `dialect` differs from the reading workspace makes +the enforcement refuse every read of that table. `--dialect` defaults to the +project backend, and kbagent refuses any other value (`INVALID_RLS_POLICY`). + +An `organization` policy is loaded by every project in the organization, +whatever their backend -- in an organization with both Snowflake and BigQuery +projects, an organization policy blocks the queries of every project on the +other backend. Prefer `targeted` there. + +Same metastore requirements as [semantic-layer-workflow.md](semantic-layer-workflow.md): +a MASTER (project admin) Storage token (`kbagent --json project info +--project P` -> `is_master_token`), same `MISSING_MASTER_TOKEN` reclassification +on a non-master token. A master token is NOT an organization admin: grants and +organization scope also need the organization-admin role, which the metastore +checks (403 otherwise). + +**Backend availability.** The `rls-policy` and `cls-policy` object types are registered by +the metastore's `2026-09-29` schema migrations. A stack whose metastore predates the `rls-policy`/`cls-policy` schema migrations answers `rls schema`/`cls schema` with a classified `NOT_FOUND` (schema-fetch failure) -- expected there, not a kbagent bug. See Workflow 7. + +Full per-command flag reference: [commands-reference.md](commands-reference.md#row-level-security-rls). +Non-obvious behaviors and version gates: [gotchas.md](gotchas.md). + +## When to use what + +| Goal | Command | +|------|---------| +| See what RLS policies already protect a project | `rls list --project P` | +| Inspect one policy's full rule set | `rls detail --project P --policy-id ID` | +| Check the live condition/rules shape before hand-writing JSON | `rls schema --project P` | +| Walk an admin through authoring a new policy interactively | `rls setup --project P` | +| Script or automate policy creation (CI, batch onboarding) | `rls create --project P --table-id T --rules '[...]'` | +| Change one field on an existing policy without touching the rest | `rls update --project P --policy-id ID --table-id/--dialect/--rules ...` | +| Share one policy with sibling projects (organization admin) | `rls create`/`rls update ... --target-project ALIAS_OR_ID` | +| Govern the table in every project of the organization (organization admin) | `rls create ... --scope organization` | +| Preview the compiled condition before writing anything | any write command + `--dry-run` | +| Remove a policy (un-protect a table) | `rls delete --project P --policy-id ID` (`--dry-run` shows it first) | + +--- + +## Workflow 1 -- Guided setup (the default path for a human admin) + +`rls setup` is interactive-terminal-only -- it needs a real TTY for the +checkbox table picker and the condition-builder prompts. Under `--json` or +without an interactive terminal (stdin or stdout piped) it exits 2 with an +`INVALID_ARGUMENT` error (a JSON envelope under `--json`) pointing at `rls create`. + +```bash +# 1. Run it directly in an interactive terminal -- NOT through kbagent http, +# NOT with --json, NOT from a scheduled agent task. +kbagent rls setup --project prod + +# 2. It shows a checkbox picker over `storage tables --project prod`'s +# result: arrows/j-k to move, space to toggle, 'a' to select all, +# enter to confirm, 'q'/esc/ctrl-c to cancel. + +# 3. For the rules, either: +# (a) build interactively -- it prompts for principal(s), then a small +# menu: comparison / IN-NOT_IN / IS NULL-IS NOT NULL / always-true, +# with an "add another rule?" loop. A typed value is read as JSON when +# it is one (42, 4.5, true); quote it ("42") to keep a string. Or +# (b) skip the builder entirely with a prepared --rules file for +# anything beyond a single simple comparison per principal: +kbagent rls setup --project prod --rules @rls_rules.json + +# 4. It prints a compiled-condition preview per selected table, then a +# single confirm (skip with --yes) before creating one `rls-policy` +# object per table -- via the exact same write path `rls create` uses. +# The dialect is the project backend unless --dialect says otherwise. +``` + +## Workflow 2 -- Scripted / non-interactive policy creation + +The path for CI, batch onboarding, or any AI-agent-driven invocation. +`--rules` always takes the same `JSON|@file|-` form the rest of kbagent uses +for structured input (see e.g. `config update --configuration`). + +```bash +# 1. Write the rules array to a file (or build it inline for something +# this short) -- ALWAYS a declarative condition, never a predicate string. +cat > rls_rules.json <<'EOF' +[ + { + "principal": "eu-analyst@example.com", + "condition": {"column": "region", "op": "eq", "value": "EU"} + }, + { + "principals": ["us-analyst@example.com", "us-lead@example.com"], + "condition": {"column": "region", "op": "eq", "value": "US"} + } +] +EOF + +# 2. ALWAYS preview first -- --dry-run runs every validation (dialect vs +# backend, rule and default shape, live schema check when +# available) and compiles a condition preview, but writes nothing. +kbagent --json rls create \ + --project prod \ + --table-id in.c-crm.invoices \ + --rules @rls_rules.json \ + --dry-run +# -> {"table": "in.c-crm.invoices", "dialect": "snowflake", +# "scope": "targeted", "target_project_ids": [], "preview": [ +# {"principal": "eu-analyst@example.com", "condition": "\"region\" = 'EU'"}, +# {"principals": ["us-analyst@example.com", "us-lead@example.com"], +# "condition": "\"region\" = 'US'"} +# ], "dry_run": true} + +# 3. Then the real write (still --json for a clean, single-document +# response -- no confirmation prompt in --json mode): +kbagent --json rls create --project prod --table-id in.c-crm.invoices --rules @rls_rules.json +``` + +A caller with no rule on a governed table is refused, not silently returned +empty -- fail-closed is the enforcement engine's whole design point (see the +RFC in `keboola-mcp-server`). Before protecting a table, make sure every +principal who legitimately needs access has a matching rule, including +yourself if you plan to verify the result via `query_data`. A principal is the +user's email, matched case-insensitively. + +## Workflow 3 -- Inspect existing policies before making a change + +```bash +# 1. What's visible to this project? +kbagent --json rls list --project prod +# -> {"project": "prod", "policies": [ +# {"id": "...", "table": "in.c-crm.invoices", "dialect": "snowflake", +# "rule_count": 2, "scope": "targeted", "owner_project_id": 12345, +# "source_project_id": null, "target_project_ids": [22222]} +# ]} + +# 2. Full rule set for one policy (needed before a partial `rls update`): +kbagent --json rls detail --project prod --policy-id +``` + +Every policy in the listing **applies** to the project: the metastore lists +what the project owns, what is granted to it (`targeted`) and every +`organization` policy, and the enforcement applies all of them. +`owner_project_id` is the project that owns (and alone may update or delete) +a `targeted` policy; it is `null` for an `organization` policy. +`target_project_ids` is shown to the owning project only. + +## Workflow 4 -- Condition primitive cookbook + +Every `condition` is one of six shapes -- there is no free-text predicate +field anywhere in this schema, by design (closes the injection surface a +hand-written-SQL model would have). `rls schema --project P` returns the +live, authoritative JSON Schema; this is the quick-reference version. + +```jsonc +// Comparison: eq | ne | gt | gte | lt | lte (value: string, number or boolean) +{"column": "status", "op": "eq", "value": "active"} + +// Membership: in | not_in +{"column": "region", "op": "in", "values": ["EU", "US"]} + +// Nullness: is_null | is_not_null -- the ONLY way to match null +{"column": "deleted_at", "op": "is_null"} + +// Boolean composition (2+ nested conditions each; there is no "not") +{"and": [ + {"column": "region", "op": "eq", "value": "EU"}, + {"column": "status", "op": "ne", "value": "draft"} +]} +{"or": [ + {"column": "region", "op": "eq", "value": "EU"}, + {"column": "region", "op": "eq", "value": "US"} +]} + +// Always applies (no filtering for this principal -- use sparingly, this +// is the escape hatch for "this admin sees everything") +{"true": true} +``` + +- `"value": null` (or `null` inside `values`) is refused: the enforcement would + render `col = NULL`, which matches no row. Use `is_null` / `is_not_null`. +- Column names are matched **case-exactly**: the enforcement quotes them + (`"region"` on Snowflake, `` `region` `` on BigQuery), and so does the preview. + +`rls create --dry-run` (or `rls setup`'s preview step) renders a condition +as a `WHERE`-clause-shaped string. It follows the enforcement's rendering +(quoted columns, `TRUE`/`FALSE`), but it is kbagent's own renderer -- the +engine that decides what a query returns lives in `keboola-mcp-server`'s +`rls.py` (sqlglot-based). Treat the preview as "does this look like what I +meant," not as proof of what will be enforced. + +## Workflow 4b -- Groups, a default and identity placeholders (schema 1.1.0) + +*(since vNEXT)* + +One policy for many readers instead of one rule per person. Groups are the IdP +`groups` claim strings exactly as delivered (Entra may deliver object ids); +Keboola never manages membership. + +```bash +cat > orders_rules.json <<'EOF' +[ + {"groups": ["sales-eu"], + "condition": {"column": "region", "op": "in", "values": ["EU"]}}, + {"groups": ["sales-reps"], + "condition": {"column": "owner_email", "op": "eq", "value": {"$identity": "email"}}}, + {"principals": ["auditor@example.com"], + "condition": {"true": true}} +] +EOF +kbagent --json rls create --project prod --table-id in.c-sales.orders \ + --rules @orders_rules.json --default '{"false": true}' --dry-run +``` + +A reader in `sales-eu` and `sales-reps` sees +`"region" IN ('EU') OR "owner_email" = `; the auditor sees every +row; anyone else sees no rows (the `default`), instead of an error. + +- `{"$identity": "email"}` (as `value`) and `{"$identity": "groups"}` (as + `values`) are resolved by the enforcement per reader, as bound literals. +- `{"false": true}` matches no row -- useful as a `default` or a rule. +- A default cannot be removed by `rls update` (the metastore's partial update + cannot delete a key); recreate the policy instead. +- MCP OAuth users have an email but no IdP groups, so `groups` rules only + match readers whose identity carries a groups claim. + +## Workflow 4c -- Default deny and the wildcard (governed workspaces) + +*(since vNEXT)* + +In a **governed workspace** (the platform creates every workspace governed once +RLS/CLS is switched on for the organization or project) nothing is readable +until a policy says so: a table with **no** `rls-policy` is refused, and so is a +table whose policies match the reader in no rule. Admins and organization admins +are not exempt. An admin who sees no data has not authored the policies yet -- +author them before switching RLS/CLS on. + +To open a table to every identified reader, use the wildcard selector `"*"` +(`principal`, `principals` or `groups`, schema 1.1.0) with an explicit condition: + +```bash +kbagent --json rls create --project prod --table-id in.c-ref.countries \ + --rules '[{"principal": "*", "condition": {"true": true}}]' --dry-run +``` + +- `"*"` must be the **only** entry of a `principals` / `groups` list + (`["*", "a@example.com"]` is refused locally); it never matches a reader with + no identity; it cannot widen a stricter policy on the same table (policies + AND together). +- It needs a metastore whose `rls-policy` schema is 1.1.0 or later: on a 1.0.0 + schema `"*"` is a literal that matches nobody, so kbagent refuses it. +- A debug or "see everything" user is a normal rule (for example + `{"groups": ["debug"], "condition": {"true": true}}`), not a bypass flag. +- A `cls-policy` alone never grants rows: a table needs an `rls-policy` (the + wildcard one if everyone may read all rows) before its columns matter. + +## Workflow 5 -- Share a policy with sibling projects (targeted grants) + +`--target-project` (on `create`/`setup`/`update`) takes a registered alias or a +project ID, repeatable or comma-separated, de-duplicated. On `create` the grants +travel in the create request itself (one transaction). On `update` the list +REPLACES the grants of a `targeted` policy, and `--clear-target-projects` revokes +them all. Granting needs the organization-admin role; on `update` the grants +change before the rules, so a refused grant writes nothing. + +An `organization` policy has no grants and cannot be narrowed -- delete it and +create a `targeted` one instead. + +```bash +kbagent --json rls create \ + --project prod \ + --table-id in.c-crm.invoices \ + --rules @rls_rules.json \ + --target-project customer-a,33333 \ + --dry-run +# -> {"scope": "targeted", "target_project_ids": [22222, 33333], ...} +``` + +Omitting `--target-project` on a later `rls update` leaves the grants +untouched -- pass the FULL desired list to change them, not just an addition. + +## Workflow 6 -- Update or revoke a policy safely + +`rls update` changes only the flags you pass. It reads the policy, validates the +MERGED result (the metastore's partial update validates only the keys it +receives), then sends a partial update (`PATCH`) with just the changed keys -- +keys kbagent does not know and concurrent changes to other keys survive. The +result is read back after the write. + +```bash +# Change only the rules, leave table/dialect/scope/grants exactly as they are: +kbagent --json rls update \ + --project prod \ + --policy-id \ + --rules @updated_rls_rules.json \ + --dry-run # preview first, same as create + +kbagent --json rls update --project prod --policy-id --rules @updated_rls_rules.json + +# Revoke entirely (un-protects the table). Show it first, then delete: +kbagent --json rls delete --project prod --policy-id --dry-run +kbagent rls delete --project prod --policy-id +# --yes to skip the confirmation prompt in a script; --json also skips it. +# `delete` is destructive-class: `--deny-destructive` blocks it. +``` + +## Workflow 7 -- Troubleshooting errors + +On a stack whose metastore predates the `rls-policy`/`cls-policy` schemas, +EVERY command in this group fails with a classified `NOT_FOUND` -- expected, +not a kbagent bug. Tell it apart from a real problem: + +```bash +kbagent --json rls schema --project prod +``` + +- **`NOT_FOUND` / "Could not fetch the rls-policy schema: ..."** -- the + object type isn't registered on this stack's metastore (same for + `cls schema`). Nothing here is broken; use a stack with a newer metastore. + `detail`/`update`/`delete` also answer `NOT_FOUND` for a policy this token + may not read (owned by another project). +- **`MISSING_MASTER_TOKEN`** -- the registered token for this project isn't a + master token. `kbagent project info --project P` -> `is_master_token`, then + `project edit --project P --token ...` with a master token. +- **`ACCESS_DENIED` (403)** on `--target-project`, `--clear-target-projects` or + `--scope organization` -- the token's user is not an organization admin. +- **`INVALID_RLS_POLICY`** -- the policy failed validation: a dialect other than + the project backend, a malformed rule or default, a null comparison, or the + live JSON-Schema check. The message lists every + violation; nothing was written. +- **`ALREADY_EXISTS`** -- the project already has a policy for this table + (policies are named by table). `rls update` or `rls delete` it. +- **`INVALID_ARGUMENT` / exit 2** -- a bad option value: an unknown + `--target-project`, `--target-project` with `--scope organization`, or an + update with nothing to change. +- **`CONFIG_ERROR` / exit 5** -- the `--project` alias isn't registered + locally. `kbagent project list`. + +--- + +## Reference: RLS contract gotchas + +- [gotchas.md](gotchas.md) -- search "rls" for the full list, version-tagged. +- One `rls-policy` object per protected table, never one blob per project. +- Every policy a project can list applies to it -- an `organization` policy + governs the table in every project of the organization. +- `--scope project` does not exist anywhere in this command group. This is + intentional, not a missing feature -- see the RFC in + `keboola-mcp-server`'s `feature_spec/rls_query_tool/RFC.md`. +- `rls setup` has no REST route (`kbagent serve`) -- it is genuinely + terminal-only, same carve-out as `auth register-projects`'s picker. + +--- + +## Column-Level Security (CLS) + +`kbagent cls` authors `cls-policy` objects: one per protected table, each rule a selector +(`principal`, `principals` or IdP `groups`) and the `visible_columns` those readers may see +(an allowlist -- unlisted columns are omitted from the result; masking is not supported). +It mirrors `rls` (`list`, `detail`, `schema`, `create`, `update`, `delete`) with the same +scope rule and default, the same write permissions, dialect check, `--dry-run`/`--yes` +flags and partial `update`; there is no `cls setup` wizard and no `default`. +Enforcement and RLS+CLS composition happen in `keboola-mcp-server`'s `query_data`, +gated by the same `row-level-security` feature. + +```bash +kbagent --json cls schema --project prod # live shape +kbagent --json cls list --project prod # expect [] on a fresh project + +# Dry-run first: prints each principal's projection, writes nothing. +kbagent cls create --project prod --table-id in.c-crm.customers \ + --rules '[{"principal":"analyst@example.com","visible_columns":["id","region","amount"]}, + {"principals":["a@example.com","b@example.com"],"visible_columns":["id"]}]' --dry-run + +kbagent cls create --project prod --table-id in.c-crm.customers --rules @cls_rules.json --yes +kbagent --json cls update --project prod --policy-id --rules @cls_rules_v2.json +kbagent cls delete --project prod --policy-id --yes +``` + +- A table can carry both an `rls-policy` and a `cls-policy`; author them separately. +- `INVALID_CLS_POLICY` -- the policy failed validation (exactly one of `principal`/`principals`/ + `groups` per rule, non-empty `visible_columns` of `[A-Za-z0-9_]+` names, the dialect check, + no `default`, plus the live JSON Schema). Nothing was written. +- Within one CLS policy, an identity several rules match sees the union of their + `visible_columns`; several CLS policies on one table intersect (since vNEXT). diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index e781987b..113b90f2 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -14,6 +14,7 @@ from .commands.billing import billing_app from .commands.branch import branch_app from .commands.changelog import changelog_command +from .commands.cls import cls_app from .commands.component import component_app from .commands.config import config_app from .commands.context import context_command @@ -35,6 +36,7 @@ from .commands.permissions import permissions_app from .commands.project import project_app from .commands.repl import repl_command +from .commands.rls import rls_app from .commands.schedule import schedule_app from .commands.search import search_command from .commands.semantic_layer import semantic_layer_app @@ -61,6 +63,7 @@ from .services.auth_service import AuthService from .services.billing_service import BillingService from .services.branch_service import BranchService +from .services.cls_service import ClsService from .services.component_service import ComponentService from .services.config_service import ConfigService from .services.data_app_git_service import DataAppGitService @@ -81,6 +84,7 @@ from .services.org_service import OrgService from .services.project_service import ProjectService from .services.repo_validate_service import RepoValidateService +from .services.rls_service import RlsService from .services.schedule_service import ScheduleService from .services.search_service import SearchService from .services.semantic_layer_service import SemanticLayerService @@ -163,6 +167,8 @@ app.add_typer(encrypt_app, name="encrypt", rich_help_panel=_DEV) app.add_typer(semantic_layer_app, name="semantic-layer", rich_help_panel=_DEV) app.add_typer(semantic_layer_app, name="sl", rich_help_panel=_DEV, hidden=True) +app.add_typer(rls_app, name="rls", rich_help_panel=_DEV) +app.add_typer(cls_app, name="cls", rich_help_panel=_DEV) app.add_typer(http_app, name="http", rich_help_panel=_DEV) app.add_typer(agent_app, name="agent", rich_help_panel=_DEV) app.add_typer(dev_portal_app, name="dev-portal", rich_help_panel=_DEV) @@ -340,6 +346,8 @@ def main( data_app_service = DataAppService(config_store=config_store) data_app_git_service = DataAppGitService(config_store=config_store) semantic_layer_service = SemanticLayerService(config_store=config_store) + rls_service = RlsService(config_store=config_store) + cls_service = ClsService(config_store=config_store) repo_validate_service = RepoValidateService(config_store=config_store) kai_service = KaiService(config_store=config_store) docs_service = DocsService(config_store=config_store) @@ -401,6 +409,8 @@ def main( ctx.obj["data_app_service"] = data_app_service ctx.obj["data_app_git_service"] = data_app_git_service ctx.obj["semantic_layer_service"] = semantic_layer_service + ctx.obj["rls_service"] = rls_service + ctx.obj["cls_service"] = cls_service ctx.obj["repo_validate_service"] = repo_validate_service ctx.obj["kai_service"] = kai_service ctx.obj["docs_service"] = docs_service diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index 5414604a..c78514fa 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -12,6 +12,7 @@ import os import secrets import sys +from collections.abc import Callable from pathlib import Path from typing import Any @@ -25,7 +26,7 @@ EXIT_JOB_TIMEOUT_TERMINATED, EXIT_PERMISSION_DENIED, ) -from ..errors import ErrorCode, KeboolaApiError, PermissionDeniedError +from ..errors import ConfigError, ErrorCode, KeboolaApiError, PermissionDeniedError from ..output import OutputFormatter @@ -451,6 +452,35 @@ def map_error_to_exit_code(exc: KeboolaApiError) -> int: return 1 +def exit_code_for(exc: ConfigError | KeboolaApiError) -> int: + """The CLI exit code for a service error: config 5, a bad option value 2, else by error code.""" + if isinstance(exc, ConfigError): + return 5 + if exc.error_code == ErrorCode.INVALID_ARGUMENT: + return 2 + return map_error_to_exit_code(exc) + + +def handle_service_call[T]( + ctx: typer.Context, func: Callable[..., T], *args: Any, **kwargs: Any +) -> T: + """Run a service call; a ``ConfigError`` / ``KeboolaApiError`` becomes a formatted error + exit.""" + formatter = get_formatter(ctx) + try: + return func(*args, **kwargs) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=exit_code_for(exc)) from None + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=exit_code_for(exc)) from None + + def emit_project_warnings(formatter: OutputFormatter, result: dict) -> None: """Emit warnings from multi-project operation results. diff --git a/src/keboola_agent_cli/commands/_semantic_layer_helpers.py b/src/keboola_agent_cli/commands/_semantic_layer_helpers.py index 8d1d6838..7d899893 100644 --- a/src/keboola_agent_cli/commands/_semantic_layer_helpers.py +++ b/src/keboola_agent_cli/commands/_semantic_layer_helpers.py @@ -13,9 +13,9 @@ import typer -from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..errors import ErrorCode from ._checkbox_select import CheckboxItem, CheckboxUnavailable, checkbox_select -from ._helpers import check_cli_operation, get_formatter, get_service, map_error_to_exit_code +from ._helpers import check_cli_operation, get_formatter, get_service, handle_service_call class ScopeChoice(StrEnum): @@ -43,29 +43,8 @@ class ItemType(StrEnum): GLOSSARY = "glossary" -def _handle_service_call(ctx: typer.Context, func, *args, **kwargs): # type: ignore[no-untyped-def] - """Run a service call, mapping ``ConfigError`` / ``KeboolaApiError`` to exit codes. - - Returns the service result on success; on failure, prints the structured - error envelope (JSON mode) or a red error line (human mode) and raises - ``typer.Exit`` with the appropriate code. - """ - formatter = get_formatter(ctx) - try: - return func(*args, **kwargs) - except ConfigError as exc: - formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) - raise typer.Exit(code=5) from None - except KeboolaApiError as exc: - formatter.error( - message=exc.message, - error_code=exc.error_code, - retryable=exc.retryable, - details=exc.details, - ) - # A bad option value is a usage error (exit 2), not a general failure. - is_usage_error = exc.error_code == ErrorCode.INVALID_ARGUMENT - raise typer.Exit(code=2 if is_usage_error else map_error_to_exit_code(exc)) from None +# The handler is shared by every metastore-backed command group (semantic-layer, rls, cls). +_handle_service_call = handle_service_call def _is_stdin_tty() -> bool: diff --git a/src/keboola_agent_cli/commands/cls.py b/src/keboola_agent_cli/commands/cls.py new file mode 100644 index 00000000..bcac5a2f --- /dev/null +++ b/src/keboola_agent_cli/commands/cls.py @@ -0,0 +1,200 @@ +"""Column-level security commands. + +Thin CLI layer over :class:`ClsService`, the column-level sibling of +``kbagent rls``. Six subcommands (no interactive ``setup`` wizard -- a CLS +rule is just a principal plus a column allowlist, so ``create`` covers it): + +- ``cls list`` -- policies visible to a project. +- ``cls detail`` -- one policy's full rules. +- ``cls schema`` -- the live ``cls-policy`` JSON Schema from the metastore. +- ``cls create`` -- author a policy for one table (write). +- ``cls update`` -- partial (PATCH) update of an existing policy (write). +- ``cls delete`` -- remove a policy (destructive). + +Each rule is ``{principal|principals, visible_columns: [...]}``: the listed +columns are the only ones that principal can read (allowlist projection; +masking is not supported). As with ``rls``, every write is authored at +``targeted`` scope unless ``--scope organization`` is passed -- never +``project``. Enforcement happens in ``keboola-mcp-server``'s +``query_data``; kbagent only authors policies. + +``list``/``detail``/``schema`` are read-only and safe under ``--deny-writes``; +``create``/``update`` are gated as ``admin``, ``delete`` and ``--scope +organization`` as ``destructive`` (``cls.*`` in ``OPERATION_REGISTRY`` / +``FLAG_ESCALATIONS``). +""" + +from __future__ import annotations + +import typer + +from ._helpers import check_cli_permission +from .rls import ( + DIALECT_HELP, + SCOPE_HELP, + TABLE_HELP, + TARGET_HELP, + Dialect, + PolicyGroup, + PolicyScope, + create_policy, + delete_policy, + list_policies, + print_schema, + show_policy, + update_policy, +) + +cls_app = typer.Typer( + help=( + "Manage column-level security policies (metastore-backed, per-principal column " + "allowlist). 'list'/'detail'/'schema' are read-only; 'create'/'update'/'delete' " + "are gated as admin -- see CONTRIBUTING.md." + ) +) + +CLS = PolicyGroup( + name="cls", + rules_shape="{principal|principals, visible_columns}", + rule_text=lambda rule: ", ".join(rule.get("visible_columns") or []), + preview_text=lambda entry: f"SELECT {', '.join(entry.get('visible_columns') or [])}", +) + + +@cls_app.callback(invoke_without_command=True) +def _cls_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "cls") + + +@cls_app.command("list") +def cls_list( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), +) -> None: + """List column-level security policies visible to a project.""" + list_policies(ctx, CLS, project) + + +@cls_app.command("detail") +def cls_detail( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + policy_id: str = typer.Option(..., "--policy-id", help="CLS policy ID"), +) -> None: + """Show one CLS policy's full rule set.""" + show_policy(ctx, CLS, project, policy_id) + + +@cls_app.command("schema") +def cls_schema( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", help="Project alias -- fetch the live schema from this stack" + ), +) -> None: + """Print the live ``cls-policy`` JSON Schema fetched from the metastore. + + Live-only (no bundled snapshot): if the metastore of that stack does not + have the ``cls-policy`` object type registered, this fails with a clean, + classified ``NOT_FOUND`` error. + """ + print_schema(ctx, CLS, project) + + +@cls_app.command("create") +def cls_create( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", help="Project alias -- the project that owns the policy" + ), + table_id: str = typer.Option(..., "--table-id", help=TABLE_HELP), + rules: str = typer.Option( + ..., + "--rules", + help=( + f"JSON|@file|- array of {CLS.rules_shape} objects; visible_columns is the " + "allowlist of columns that principal can read" + ), + ), + dialect: Dialect | None = typer.Option(None, "--dialect", help=DIALECT_HELP), + scope: PolicyScope = typer.Option(PolicyScope.TARGETED, "--scope", help=SCOPE_HELP), + target_project: list[str] | None = typer.Option(None, "--target-project", help=TARGET_HELP), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview the projection without writing"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Create one CLS policy for one table (``targeted`` scope unless ``--scope organization``).""" + create_policy( + ctx, + CLS, + alias=project, + table=table_id, + dialect=dialect, + rules=rules, + scope=scope, + target_project=target_project, + dry_run=dry_run, + yes=yes, + ) + + +@cls_app.command("update") +def cls_update( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + policy_id: str = typer.Option(..., "--policy-id", help="CLS policy ID"), + table_id: str | None = typer.Option(None, "--table-id", help="New table (unset = unchanged)"), + dialect: Dialect | None = typer.Option( + None, "--dialect", help="New dialect, must match the project backend (unset = unchanged)" + ), + rules: str | None = typer.Option( + None, "--rules", help="New JSON|@file|- rules array (unset = unchanged)" + ), + target_project: list[str] | None = typer.Option( + None, "--target-project", help=f"{TARGET_HELP}; replaces the list (unset = unchanged)" + ), + clear_target_projects: bool = typer.Option( + False, + "--clear-target-projects", + help="Revoke every project the policy is shared with (cannot be combined with --target-project)", + ), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview the projection without writing"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Update an existing CLS policy. + + Only the flags you pass change -- an omitted flag keeps the policy's + current value. The write is a partial update (PATCH) of just those keys. + """ + update_policy( + ctx, + CLS, + alias=project, + policy_id=policy_id, + table=table_id, + dialect=dialect, + rules=rules, + target_project=target_project, + clear_target_projects=clear_target_projects, + dry_run=dry_run, + yes=yes, + ) + + +@cls_app.command("delete") +def cls_delete( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + policy_id: str = typer.Option(..., "--policy-id", help="CLS policy ID"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show the policy without deleting it"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Delete a CLS policy -- its table's columns become unrestricted.""" + delete_policy( + ctx, + CLS, + "Its table's columns become unrestricted.", + alias=project, + policy_id=policy_id, + dry_run=dry_run, + yes=yes, + ) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 354d6ab9..7dfa886b 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -2085,6 +2085,106 @@ and /items/{{kind}} take `scope` + `target_projects`. +### Row-Level Security (RLS) + +Author `rls-policy` metastore objects: one object per protected table, a +declarative condition primitive per rule (never free-text SQL). Policy schema +1.1.0: a rule selects identities by principal, principals or IdP `groups`; +within a policy every rule matching one identity applies and the conditions +combine with OR; several policies on one table combine with AND (an added +policy only narrows); a reader with no identity is always refused; +the wildcard `"*"` (sole entry of `principal`/`principals`/`groups`) matches every identified reader and is how a table is opened to everyone under default deny; in a governed workspace +(the platform denies by default, admins never exempt) a table with no rls-policy is refused; an +optional policy `default` covers an identified reader no rule matches +(absent = refused). Needs an MCP engine that reads 1.1.0. Same +metastore/master-token requirements as Semantic Layer above. +**Scope**: `targeted` by default (the owning project plus --target-project +grants); `--scope organization` governs the table in EVERY project of the +organization (destructive-class, organization-admin only) -- use it only +when the user asks for org-wide. There is NO project scope. A project admin +may write `targeted` policies of its own project without grants; grants and +organization scope need the organization-admin role (403 otherwise). +Enforced only where the project has the `row-level-security` feature. +Writes refuse a dialect other than the project backend (the enforcement +would refuse every read of that table). A stack whose metastore predates the +`rls-policy`/`cls-policy` schemas answers `rls schema` with a classified +NOT_FOUND schema-fetch error -- expected there, not a kbagent bug. +Enforcement (the actual SQL rewrite) happens in `keboola-mcp-server`'s +`query_data`, not here -- kbagent only authors policies. + + kbagent rls list --project P + List RLS policies visible to -- and applied in -- a project (id, table, + dialect, rule_count, scope, owner_project_id, target_project_ids). + + kbagent rls detail --project P --policy-id ID + Full rule set for one policy. + + kbagent rls schema --project P + Live rls-policy JSON Schema fetched from the metastore. No offline + bundled snapshot (unlike `flow schema`) -- always live-only. + + kbagent rls create --project P --table-id TABLE_ID --rules JSON|@file|- [--default JSON|@file|-] [--dialect snowflake|bigquery] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--dry-run] [--yes] + Create one policy for one table. --rules is a JSON array of + {{principal|principals|groups, condition}} objects -- condition is a + primitive tree (column/op/value, in/not_in, is_null/is_not_null, and/or + nesting, `{{"true": true}}` or `{{"false": true}}`), never a raw predicate + string; use is_null, never value null; `value: {{"$identity": "email"}}` + / `values: {{"$identity": "groups"}}` are resolved per reader. --default + (e.g. `{{"false": true}}` = no rows) covers identities no rule matches. + --dialect defaults to the project backend. + --target-project (alias or ID, repeatable or comma-separated) grants + other projects in the create request. --dry-run previews the compiled + condition (columns quoted like the enforcement) without writing. + + kbagent rls update --project P --policy-id ID [--table-id ...] [--dialect ...] [--rules JSON|@file|-] [--default JSON|@file|-] [--target-project ALIAS|ID ... | --clear-target-projects] [--dry-run] [--yes] + Partial update (PATCH of the changed keys only); the merged policy is + validated first and the result is re-read after the write. A default + cannot be removed by an update -- recreate the policy. + + kbagent rls delete --project P --policy-id ID [--dry-run] [--yes] + Delete a policy, un-protecting its table (destructive-class). + + kbagent rls setup --project P [--dialect D] [--rules JSON|@file|-] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--yes] + Guided, INTERACTIVE-TERMINAL-ONLY wizard: checkbox table picker (reuses + `storage tables`), then either the interactive column/op/value condition + builder or the same --rules escape hatch `create` takes, then a preview + + confirm, then one `rls create` call per selected table. Under --json or + without an interactive terminal it exits 2 (INVALID_ARGUMENT) pointing at + `rls create`. + +### Column-Level Security (CLS) + +Author `cls-policy` metastore objects: one object per protected table, a +`visible_columns` allowlist per rule (unlisted columns are omitted from +that identity's result; masking is not supported). Rules select by +principal, principals or `groups`; within a policy an identity several rules +match sees the union of their columns, several policies intersect; there is +no `default`. Sibling of RLS above -- +same scope default, permissions, checks and partial update; no `setup` +wizard. `query_data` in `keboola-mcp-server` composes RLS and CLS; a +principal with no rule for a governed table is refused there. + + kbagent cls list --project P + List CLS policies visible to a project. + + kbagent cls detail --project P --policy-id ID + Full rule set for one policy. + + kbagent cls schema --project P + Live cls-policy JSON Schema from the metastore (live-only). + + kbagent cls create --project P --table-id TABLE_ID --rules JSON|@file|- [--dialect snowflake|bigquery] [--scope targeted|organization] [--target-project ALIAS|ID ...] [--dry-run] [--yes] + Create one policy for one table. --rules is a JSON array of + {{principal|principals|groups, visible_columns: [col, ...]}} objects. + --dry-run prints each selector's allowed projection without writing. + + kbagent cls update --project P --policy-id ID [--table-id ...] [--dialect ...] [--rules JSON|@file|-] [--target-project ALIAS|ID ... | --clear-target-projects] [--dry-run] [--yes] + Partial update, same as `rls update`. + + kbagent cls delete --project P --policy-id ID [--dry-run] [--yes] + Delete a policy; its table's columns become unrestricted. + + ### Self-call HTTP (inside `kbagent serve` subprocesses) kbagent http get PATH [--timeout SECONDS] diff --git a/src/keboola_agent_cli/commands/rls.py b/src/keboola_agent_cli/commands/rls.py new file mode 100644 index 00000000..3cd60006 --- /dev/null +++ b/src/keboola_agent_cli/commands/rls.py @@ -0,0 +1,746 @@ +"""Row-level security commands (CLI-17). + +Thin CLI layer over :class:`RlsService`. Seven subcommands: + +- ``rls list`` -- policies visible to a project. +- ``rls detail`` -- one policy's full rules. +- ``rls schema`` -- the live ``rls-policy`` JSON Schema from the metastore. +- ``rls create`` -- author a policy for one table (write). +- ``rls update`` -- partial (PATCH) update of an existing policy (write). +- ``rls delete`` -- remove a policy (destructive). +- ``rls setup`` -- guided, interactive-terminal-only wizard: pick tables via + a checkbox picker, build conditions, preview, then create one policy per + table via the same ``create_policy`` path ``create`` uses. + +**Every policy this group writes is authored at ``targeted`` scope (the +default: the owning project plus the ``--target-project`` grants) or, only +when asked for with ``--scope organization``, at ``organization`` scope** +(every project in the organization). ``--scope`` offers no ``project``: the +metastore schema does not support it for policies (see the RFC in +``keboola-mcp-server``'s ``feature_spec/rls_query_tool/RFC.md``). + +On a stack whose metastore predates the ``rls-policy`` schema every command here +answers with a clean, classified error -- see ``gotchas.md``'s RLS/CLS entry. + +``list``/``detail``/``schema`` are read-only and safe under ``--deny-writes``. +``create``/``update``/``setup`` are gated as ``admin`` (organization-level, +not merely ``write``); ``delete`` and ``--scope organization`` are +``destructive`` -- see ``rls.*`` in ``OPERATION_REGISTRY`` / ``FLAG_ESCALATIONS`` +(``permissions.py``). +""" + +from __future__ import annotations + +import contextlib +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import typer +from rich.syntax import Syntax +from rich.table import Table + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..services._rls_condition import COMPARISON_SQL, Dialect +from ..services.rls_service import PolicyScope +from ._checkbox_select import CheckboxItem, CheckboxUnavailable, _stdio_is_tty, checkbox_select +from ._helpers import ( + check_cli_operation, + check_cli_permission, + exit_code_for, + get_formatter, + get_service, + handle_service_call, + parse_json_arg, +) + +rls_app = typer.Typer( + help=( + "Manage row-level security policies (metastore-backed). " + "'list'/'detail'/'schema' are read-only; 'create'/'update'/'setup'/'delete' " + "are gated as admin -- see CONTRIBUTING.md." + ) +) + + +DIALECT_HELP = "Workspace SQL dialect; must match the project backend (default: the backend)" +SCOPE_HELP = ( + "targeted (default): the owning project plus --target-project grants. organization: " + "EVERY project of the organization that has the row-level-security feature -- an explicit, " + "organization-admin-only choice, gated as destructive." +) +TARGET_HELP = ( + "Project alias or ID the policy also applies to (repeatable or comma-separated; " + "--scope targeted only; granting needs the organization-admin role)" +) +TABLE_HELP = "Storage table ID, e.g. in.c-crm.invoices" +DEFAULT_HELP = ( + "JSON|@file|- condition for identities no rule matches, e.g. '{\"false\": true}' (no rows); " + "omitted, their reads are refused" +) + + +@dataclass(frozen=True) +class PolicyGroup: + """What differs between the ``rls`` and ``cls`` command groups; everything else is shared.""" + + name: str # "rls" | "cls": the permission prefix, `_service`, `-policy` + rules_shape: str # shown in the --rules parse error + rule_text: Callable[[dict[str, Any]], str] # one stored rule's restriction + preview_text: Callable[[dict[str, Any]], str] # one --dry-run preview entry's restriction + + @property + def service(self) -> str: + return f"{self.name}_service" + + @property + def label(self) -> str: + return self.name.upper() + + +RLS = PolicyGroup( + name="rls", + rules_shape="{principal|principals, condition}", + rule_text=lambda rule: str(rule.get("condition")), + preview_text=lambda entry: f"WHERE {entry.get('condition')}", +) + + +@rls_app.callback(invoke_without_command=True) +def _rls_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "rls") + + +# --------------------------------------------------------------------------- +# Shared helpers (also used by `cls`) +# --------------------------------------------------------------------------- + + +def _confirm_or_exit(formatter: Any, question: str) -> None: + if not typer.confirm(question): + formatter.console.print("[yellow]Aborted.[/yellow]") + raise typer.Exit(code=0) + + +def gate_scope( + ctx: typer.Context, group: PolicyGroup, scope: PolicyScope, target_project: list[str] | None +) -> None: + """Validate ``--scope`` before any API call or prompt. + + ``--scope organization`` governs the table in every project: a destructive-class flag. It has + no grants, so ``--target-project`` with it is a usage error (exit 2). + """ + if scope != PolicyScope.ORGANIZATION: + return + check_cli_operation(ctx, f"{group.name}.create --scope organization") + if target_project: + get_formatter(ctx).error( + message="--target-project requires --scope targeted (organization scope has no grants)", + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) + + +def _principal_text(item: dict[str, Any]) -> str: + """A rule's (or preview entry's) selector: ``a@x.com``, ``a@x.com, b@x.com`` or ``groups: g1, g2``.""" + if "groups" in item: + return "groups: " + ", ".join(item["groups"]) + principal = item.get("principal") or item.get("principals") or [] + text = principal if isinstance(principal, str) else ", ".join(principal) + return f"{text} (every identified reader)" if text == "*" else text + + +def _format_policy_table(formatter: Any, policies: list[dict[str, Any]]) -> None: + tbl = Table( + "ID", + "Table", + "Dialect", + "Rules", + "Scope", + "Owner Project", + "Target Projects", + show_header=True, + header_style="bold cyan", + ) + for policy in policies: + tbl.add_row( + str(policy.get("id", "")), + str(policy.get("table", "")), + str(policy.get("dialect", "")), + str(policy.get("rule_count", 0)), + str(policy.get("scope", "")), + str(policy.get("owner_project_id") or "(organization)"), + ", ".join(str(p) for p in policy.get("target_project_ids", [])), + ) + formatter.console.print(tbl) + + +def _print_policy(formatter: Any, group: PolicyGroup, row: dict[str, Any]) -> None: + formatter.console.print( + f"\n[bold]{row.get('table', '')}[/bold] [dim](id {row.get('id', '')})[/dim]" + ) + formatter.console.print(f" Dialect: {row.get('dialect', '')}") + formatter.console.print(f" Scope: {row.get('scope', '')}") + formatter.console.print( + f" Owner project: {row.get('owner_project_id') or '(organization)'}" + ) + targets = row.get("target_project_ids") or [] + if targets: + formatter.console.print(f" Target projects: {', '.join(str(t) for t in targets)}") + rules = row.get("rules") or [] + formatter.console.print(f" Rules ({len(rules)}):") + for rule in rules: + formatter.console.print(f" - {_principal_text(rule)}: {group.rule_text(rule)}") + if row.get("default") is not None: + formatter.console.print(f" Default: {row['default']}") + + +def _print_preview(formatter: Any, group: PolicyGroup, result: dict[str, Any]) -> None: + formatter.console.print( + f"\n[bold]Preview[/bold] -- {result.get('table', '')} " + f"[dim]({result.get('dialect', '')}, scope={result.get('scope', '')})[/dim]" + ) + for entry in result.get("preview", []): + formatter.console.print(f" {_principal_text(entry)}: {group.preview_text(entry)}") + if result.get("default_preview") is not None: + formatter.console.print(f" (anyone else): WHERE {result['default_preview']}") + + +def _print_warnings(formatter: Any, result: dict[str, Any]) -> None: + """Human mode only -- in `--json` mode the warnings are already part of the payload.""" + for warning in result.get("warnings", []): + formatter.warning(warning) + + +def _parse_rules_arg(formatter: Any, raw: str, shape: str) -> list[dict[str, Any]]: + """Parse ``--rules``; ``shape`` is only the hint in the error.""" + try: + parsed = parse_json_arg(raw, label="--rules") + except ValueError as exc: + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) + raise typer.Exit(code=2) from None + if not isinstance(parsed, list): + formatter.error( + message=f"--rules must be a JSON array of {shape} objects", + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) from None + return parsed + + +def _parse_default_arg(formatter: Any, raw: str | None) -> dict[str, Any] | None: + """Parse ``--default`` (a condition object), or ``None`` when the option was not given.""" + if raw is None: + return None + try: + parsed = parse_json_arg(raw, label="--default") + except ValueError as exc: + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) + raise typer.Exit(code=2) from None + if not isinstance(parsed, dict): + formatter.error( + message='--default must be a JSON condition object, e.g. {"false": true}', + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) from None + return parsed + + +def list_policies(ctx: typer.Context, group: PolicyGroup, project: str) -> None: + formatter = get_formatter(ctx) + service = get_service(ctx, group.service) + result = handle_service_call(ctx, service.list_policies, alias=project) + + if formatter.json_mode: + formatter.output(result) + elif not result.get("policies"): + formatter.console.print(f"[dim]No {group.label} policies found.[/dim]") + else: + _format_policy_table(formatter, result["policies"]) + + +def show_policy(ctx: typer.Context, group: PolicyGroup, project: str, policy_id: str) -> None: + formatter = get_formatter(ctx) + service = get_service(ctx, group.service) + result = handle_service_call(ctx, service.get_policy, alias=project, policy_id=policy_id) + + if formatter.json_mode: + formatter.output(result) + else: + _print_policy(formatter, group, result) + + +def print_schema(ctx: typer.Context, group: PolicyGroup, project: str) -> None: + """The live schema, no offline bundled snapshot.""" + formatter = get_formatter(ctx) + # An auth/permission failure is raised (not "schema unavailable") and reported by the handler. + service = get_service(ctx, group.service) + fetch = handle_service_call(ctx, service.fetch_schema, alias=project) + if fetch.schema is None: + formatter.error( + message=f"Could not fetch the {group.name}-policy schema: {fetch.reason}", + error_code=ErrorCode.NOT_FOUND, + ) + raise typer.Exit(code=4) + + if formatter.json_mode: + formatter.output({"format": "json-schema", "source": "live", "schema": fetch.schema}) + return + formatter.console.print( + Syntax(json.dumps(fetch.schema, indent=2), "json", theme="monokai", line_numbers=False) + ) + + +def create_policy( + ctx: typer.Context, + group: PolicyGroup, + *, + rules: str, + scope: PolicyScope, + target_project: list[str] | None, + dry_run: bool, + yes: bool, + **kwargs: Any, +) -> None: + """``rls create`` / ``cls create``; ``kwargs`` are ``alias``/``table``/``dialect``.""" + gate_scope(ctx, group, scope, target_project) + formatter = get_formatter(ctx) + service = get_service(ctx, group.service) + kwargs |= { + "rules": _parse_rules_arg(formatter, rules, group.rules_shape), + "scope": scope.value, + "target_projects": target_project, + } + + if not dry_run and not yes and not formatter.json_mode: + # A validation/network error here is reported properly by the real call below. + with contextlib.suppress(ConfigError, KeboolaApiError): + _print_preview(formatter, group, service.create_policy(**kwargs, dry_run=True)) + _confirm_or_exit(formatter, f"Create {group.label} policy on {kwargs['table']}?") + + result = handle_service_call(ctx, service.create_policy, **kwargs, dry_run=dry_run) + + if formatter.json_mode: + formatter.output(result) + return + _print_warnings(formatter, result) + if not dry_run: + formatter.success( + f"Created {group.label} policy {result.get('id', '')} on {kwargs['table']}" + ) + _print_preview(formatter, group, result) + + +def update_policy( + ctx: typer.Context, + group: PolicyGroup, + *, + rules: str | None, + target_project: list[str] | None, + clear_target_projects: bool, + dry_run: bool, + yes: bool, + **kwargs: Any, +) -> None: + """``rls update`` / ``cls update``; ``kwargs`` are ``alias``/``policy_id``/``table``/``dialect``.""" + formatter = get_formatter(ctx) + parsed_rules = ( + _parse_rules_arg(formatter, rules, group.rules_shape) if rules is not None else None + ) + if clear_target_projects and target_project: + formatter.error( + message="--clear-target-projects cannot be combined with --target-project", + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) + service = get_service(ctx, group.service) + policy_id = kwargs["policy_id"] + + if not dry_run and not yes and not formatter.json_mode: + _confirm_or_exit(formatter, f"Update {group.label} policy {policy_id}?") + + result = handle_service_call( + ctx, + service.update_policy, + **kwargs, + rules=parsed_rules, + target_projects=[] if clear_target_projects else target_project, + dry_run=dry_run, + ) + + if formatter.json_mode: + formatter.output(result) + return + _print_warnings(formatter, result) + if not dry_run: + formatter.success(f"Updated {group.label} policy {policy_id}") + _print_preview(formatter, group, result) + + +def delete_policy( + ctx: typer.Context, + group: PolicyGroup, + effect: str, + *, + alias: str, + policy_id: str, + dry_run: bool, + yes: bool, +) -> None: + """``rls delete`` / ``cls delete``; ``--dry-run`` shows the policy that would be deleted.""" + formatter = get_formatter(ctx) + service = get_service(ctx, group.service) + + if not dry_run and not yes and not formatter.json_mode: + _confirm_or_exit(formatter, f"Delete {group.label} policy {policy_id}? {effect}") + + result = handle_service_call( + ctx, service.delete_policy, alias=alias, policy_id=policy_id, dry_run=dry_run + ) + + if formatter.json_mode: + formatter.output(result) + elif dry_run: + formatter.console.print(f"[bold]Would delete[/bold] {group.label} policy {policy_id}:") + formatter.console.print_json(data=result["policy"]) + else: + formatter.success(f"Deleted {group.label} policy {policy_id}") + + +# --------------------------------------------------------------------------- +# rls commands +# --------------------------------------------------------------------------- + + +@rls_app.command("list") +def rls_list( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), +) -> None: + """List row-level security policies visible to a project.""" + list_policies(ctx, RLS, project) + + +@rls_app.command("detail") +def rls_detail( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + policy_id: str = typer.Option(..., "--policy-id", help="RLS policy ID"), +) -> None: + """Show one RLS policy's full rule set.""" + show_policy(ctx, RLS, project, policy_id) + + +@rls_app.command("schema") +def rls_schema( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", help="Project alias -- fetch the live schema from this stack" + ), +) -> None: + """Print the live ``rls-policy`` JSON Schema fetched from the metastore. + + Unlike ``flow schema``, there is no offline bundled snapshot -- the + schema is authoritative only from the live metastore, and on a stack whose + metastore predates it, fetching fails with a clean, classified error. + """ + print_schema(ctx, RLS, project) + + +@rls_app.command("create") +def rls_create( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", help="Project alias -- the project that owns the policy" + ), + table_id: str = typer.Option(..., "--table-id", help=TABLE_HELP), + rules: str = typer.Option( + ..., + "--rules", + help=( + "JSON|@file|- array of {principal|principals|groups, condition} objects; rules " + "matching one identity combine with OR (see rls-workflow.md for the condition shape)" + ), + ), + default: str | None = typer.Option(None, "--default", help=DEFAULT_HELP), + dialect: Dialect | None = typer.Option(None, "--dialect", help=DIALECT_HELP), + scope: PolicyScope = typer.Option(PolicyScope.TARGETED, "--scope", help=SCOPE_HELP), + target_project: list[str] | None = typer.Option(None, "--target-project", help=TARGET_HELP), + dry_run: bool = typer.Option( + False, "--dry-run", help="Preview the compiled condition without writing" + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Create one RLS policy for one table (``targeted`` scope unless ``--scope organization``).""" + create_policy( + ctx, + RLS, + alias=project, + table=table_id, + default=_parse_default_arg(get_formatter(ctx), default), + dialect=dialect, + rules=rules, + scope=scope, + target_project=target_project, + dry_run=dry_run, + yes=yes, + ) + + +@rls_app.command("update") +def rls_update( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + policy_id: str = typer.Option(..., "--policy-id", help="RLS policy ID"), + table_id: str | None = typer.Option(None, "--table-id", help="New table (unset = unchanged)"), + dialect: Dialect | None = typer.Option( + None, "--dialect", help="New dialect, must match the project backend (unset = unchanged)" + ), + rules: str | None = typer.Option( + None, "--rules", help="New JSON|@file|- rules array (unset = unchanged)" + ), + default: str | None = typer.Option( + None, + "--default", + help="New default condition (unset = unchanged; to remove one, recreate the policy)", + ), + target_project: list[str] | None = typer.Option( + None, "--target-project", help=f"{TARGET_HELP}; replaces the list (unset = unchanged)" + ), + clear_target_projects: bool = typer.Option( + False, + "--clear-target-projects", + help="Revoke every project the policy is shared with (cannot be combined with --target-project)", + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="Preview the compiled condition without writing" + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Update an existing RLS policy. + + Only the flags you pass change -- an omitted flag keeps the policy's + current value. The write is a partial update (PATCH) of just those keys. + """ + update_policy( + ctx, + RLS, + alias=project, + policy_id=policy_id, + table=table_id, + default=_parse_default_arg(get_formatter(ctx), default), + dialect=dialect, + rules=rules, + target_project=target_project, + clear_target_projects=clear_target_projects, + dry_run=dry_run, + yes=yes, + ) + + +@rls_app.command("delete") +def rls_delete( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + policy_id: str = typer.Option(..., "--policy-id", help="RLS policy ID"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show the policy without deleting it"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Delete an RLS policy -- its table's rows are no longer filtered.""" + delete_policy( + ctx, + RLS, + "This un-protects its table.", + alias=project, + policy_id=policy_id, + dry_run=dry_run, + yes=yes, + ) + + +# --------------------------------------------------------------------------- +# rls setup (guided, interactive-terminal-only -- no REST route, see +# CONTRIBUTING.md's "genuinely terminal-only" carve-out) +# --------------------------------------------------------------------------- + +_SETUP_HINT = ( + "rls setup is an interactive picker and needs a real terminal. " + "Use `rls create --project P --table-id T --rules '[...]'` " + "directly instead (see rls-workflow.md)." +) + +_CONDITION_MENU = ( + " 1) column comparison (=, !=, >, >=, <, <=)\n" + " 2) column IN / NOT IN a list of values\n" + " 3) column IS NULL / IS NOT NULL\n" + " 4) always true (no filtering for this principal)" +) + +_COMPARISON_OPS = tuple(COMPARISON_SQL) # eq, ne, gt, gte, lt, lte +_VALUE_HINT = 'parsed as JSON when it is one (42, 4.5, true); quote it ("42") to force a string' + + +def _prompt_choice(text: str, choices: tuple[str, ...], default: str) -> str: + while True: + value = typer.prompt(text, default=default) + if value in choices: + return value + typer.echo(f"Enter one of: {', '.join(choices)}") + + +def _typed_value(raw: str) -> Any: + """A prompted value as the JSON scalar it spells (``42``, ``true``), else the text itself. + + Without this every value would be stored as a string, and the filter would compare a numeric or + boolean column to a string literal. ``null`` is not a value here: it never matches (use option 3). + """ + try: + value = json.loads(raw) + except ValueError: + return raw + return value if isinstance(value, str | int | float | bool) else raw + + +def _prompt_condition() -> dict[str, Any]: + typer.echo(_CONDITION_MENU) + choice = _prompt_choice("Choice", ("1", "2", "3", "4"), default="1") + if choice == "4": + return {"true": True} + column = typer.prompt("Column name") + if choice == "1": + op = _prompt_choice("Operator", _COMPARISON_OPS, default="eq") + value = _typed_value(typer.prompt(f"Value ({_VALUE_HINT})")) + return {"column": column, "op": op, "value": value} + if choice == "2": + op = _prompt_choice("Operator", ("in", "not_in"), default="in") + raw_values = typer.prompt(f"Values (comma-separated; each {_VALUE_HINT})") + values = [_typed_value(v.strip()) for v in raw_values.split(",") if v.strip()] + return {"column": column, "op": op, "values": values} + op = _prompt_choice("Operator", ("is_null", "is_not_null"), default="is_null") + return {"column": column, "op": op} + + +def _build_rules_interactively() -> list[dict[str, Any]]: + rules: list[dict[str, Any]] = [] + while True: + principal = typer.prompt( + "Principal (user email, comma-separated list for a group, or * for every identified reader)" + ) + principals = [p.strip() for p in principal.split(",") if p.strip()] + condition = _prompt_condition() + rule: dict[str, Any] = {"condition": condition} + if len(principals) == 1: + rule["principal"] = principals[0] + else: + rule["principals"] = principals + rules.append(rule) + if not typer.confirm("Add another rule to this policy?", default=False): + return rules + + +@rls_app.command("setup") +def rls_setup( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", help="Project alias whose tables you're protecting" + ), + dialect: Dialect | None = typer.Option(None, "--dialect", help=DIALECT_HELP), + rules: str | None = typer.Option( + None, + "--rules", + help="JSON|@file|- rules array -- skips the interactive condition builder", + ), + scope: PolicyScope = typer.Option(PolicyScope.TARGETED, "--scope", help=SCOPE_HELP), + target_project: list[str] | None = typer.Option(None, "--target-project", help=TARGET_HELP), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the final confirmation"), +) -> None: + """Guided RLS setup: pick tables, build a condition, preview, then write. + + Interactive-terminal-only (with ``--json`` or without a terminal it exits 2 + with a usage error -- use ``rls create`` there). Creates one RLS policy per + selected table, all sharing the same rules, via the exact same write path + ``rls create`` uses. + """ + # `setup` performs `create`'s writes, so an exact `rls.create` denial must cover it too. + check_cli_operation(ctx, "rls.create") + gate_scope(ctx, RLS, scope, target_project) + formatter = get_formatter(ctx) + if formatter.json_mode or not _stdio_is_tty(): + formatter.error(message=_SETUP_HINT, error_code=ErrorCode.INVALID_ARGUMENT) + raise typer.Exit(code=2) + + storage_service = get_service(ctx, "storage_service") + tables = handle_service_call(ctx, storage_service.list_tables, aliases=[project]).get( + "tables", [] + ) + if not tables: + formatter.console.print(f"[dim]No tables found in project '{project}'.[/dim]") + raise typer.Exit(code=0) + + items = [ + CheckboxItem(label=str(t.get("id", "")), hint=f"{t.get('rows_count', 0)} rows") + for t in tables + ] + try: + indices = checkbox_select(items, title=f"Select tables to protect with RLS in '{project}'") + except CheckboxUnavailable: + formatter.error(message=_SETUP_HINT, error_code=ErrorCode.INVALID_ARGUMENT) + raise typer.Exit(code=2) from None + + if not indices: + formatter.console.print("No tables selected.") + raise typer.Exit(code=0) + + parsed_rules = ( + _parse_rules_arg(formatter, rules, RLS.rules_shape) + if rules is not None + else _build_rules_interactively() + ) + service = get_service(ctx, RLS.service) + selected_tables = [str(tables[i]["id"]) for i in indices] + policies = f"{len(selected_tables)} RLS polic{'y' if len(selected_tables) == 1 else 'ies'}" + kwargs = { + "alias": project, + "dialect": dialect, # None = the project backend, resolved by the service + "rules": parsed_rules, + "scope": scope.value, + "target_projects": target_project, + } + + for table_id in selected_tables: + try: + preview = service.create_policy(table=table_id, **kwargs, dry_run=True) + except (ConfigError, KeboolaApiError) as exc: + formatter.console.print(f"[yellow]{table_id}: preview failed ({exc}).[/yellow]") + continue + _print_preview(formatter, RLS, preview) + + if not yes: + _confirm_or_exit(formatter, f"Create {policies}?") + + failed: list[str] = [] + exit_codes: set[int] = set() + error_codes: set[str] = set() + for table_id in selected_tables: + try: + result = service.create_policy(table=table_id, **kwargs) + except (ConfigError, KeboolaApiError) as exc: + formatter.warning(f"{table_id}: {exc}") + failed.append(table_id) + exit_codes.add(exit_code_for(exc)) + error_codes.add( + ErrorCode.CONFIG_ERROR if isinstance(exc, ConfigError) else exc.error_code + ) + continue + _print_warnings(formatter, result) + formatter.success(f"Created RLS policy {result.get('id', '')} on {table_id}") + if failed: + # Automation must not read a partly (or wholly) failed setup as success. + formatter.error( + message=f"{len(failed)} of {policies} could not be created: {', '.join(failed)}", + error_code=error_codes.pop() if len(error_codes) == 1 else ErrorCode.API_ERROR, + ) + # Same exit-code contract as `rls create`: when every failure maps to one code (auth -> 3, + # config -> 5, ...) use it; a mix is a general failure. + raise typer.Exit(code=exit_codes.pop() if len(exit_codes) == 1 else 1) diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 50d5a2ed..3d47472b 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -164,6 +164,14 @@ class ErrorCode(StrEnum): MR_NOT_READY_TO_MERGE = "MR_NOT_READY_TO_MERGE" MR_MERGE_CONFLICT = "MR_MERGE_CONFLICT" + # Row-level security (CLI-17). Same role INVALID_FLOW_DEFINITION plays + # for conditional flows: a candidate `rls-policy` body (table/dialect/ + # rules/condition) that fails op-name or JSON-Schema structural checks. + INVALID_RLS_POLICY = "INVALID_RLS_POLICY" + # Column-level security: same role for a candidate `cls-policy` body + # (table/dialect/rules/visible_columns). + INVALID_CLS_POLICY = "INVALID_CLS_POLICY" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. diff --git a/src/keboola_agent_cli/json_utils.py b/src/keboola_agent_cli/json_utils.py index 50dd0d89..4bb07c5d 100644 --- a/src/keboola_agent_cli/json_utils.py +++ b/src/keboola_agent_cli/json_utils.py @@ -1,4 +1,4 @@ -"""JSON deep-merge and nested-path utilities. +"""JSON deep-merge, nested-path and schema-validation utilities. Used by ``config update`` to patch configuration content without losing sibling keys -- the exact problem that MCP server's @@ -11,6 +11,8 @@ from dataclasses import dataclass from typing import Any +import jsonschema + # Sentinel marking "the key does not exist on this side" in a DiffEntry -- # distinct from an explicit ``None`` value, which is a legal JSON value. _ABSENT: Any = object() @@ -219,3 +221,12 @@ def find_matches_in_json( if obj is not None and match_fn(str(obj)): paths.append(path) return paths + + +def draft7_errors(document: Any, schema: dict[str, Any]) -> list[str]: + """Every Draft7 violation of ``document`` against ``schema``, as "Schema error at PATH: MSG".""" + validator = jsonschema.Draft7Validator(schema) + return [ + f"Schema error at {'/'.join(str(p) for p in err.path) or '(root)'}: {err.message}" + for err in sorted(validator.iter_errors(document), key=lambda e: list(e.path)) + ] diff --git a/src/keboola_agent_cli/metastore_client.py b/src/keboola_agent_cli/metastore_client.py index 03a8941a..5eb0f9eb 100644 --- a/src/keboola_agent_cli/metastore_client.py +++ b/src/keboola_agent_cli/metastore_client.py @@ -49,6 +49,7 @@ import logging import re +from dataclasses import dataclass from typing import Any, Literal, Self import httpx @@ -92,6 +93,8 @@ "semantic-constraint", "semantic-glossary", "semantic-reference-data", + "rls-policy", + "cls-policy", ] @@ -103,6 +106,8 @@ "semantic-constraint", "semantic-glossary", "semantic-reference-data", + "rls-policy", + "cls-policy", ) @@ -144,6 +149,36 @@ def _name_conflict(item_type: str, name: str, exc: KeboolaApiError) -> KeboolaAp ) +def _exact_int(value: Any) -> int: + """``int(value)`` without its silent conversions: a bool or a non-integral float is rejected.""" + if isinstance(value, bool): + raise TypeError("a bool is not a project id") + if isinstance(value, float) and not value.is_integer(): + raise ValueError("a non-integral float is not a project id") + return int(value) + + +_PICK_ANOTHER_NAME = "Pick another name." + + +def _already_exists( + exc: KeboolaApiError, item_type: str, name: str | None, hint: str | None +) -> KeboolaApiError: + """A duplicate-name conflict as a clean ``ALREADY_EXISTS``; ``hint`` is the caller's remedy. + + Without a hint (the semantic-layer callers) the message explains the metastore's uniqueness + rule instead (:func:`_name_conflict`). + """ + if hint is None: + return _name_conflict(item_type, name or "", exc) + return KeboolaApiError( + message=f"{item_type} with name {name!r} already exists. {hint}", + status_code=exc.status_code, + error_code=ErrorCode.ALREADY_EXISTS, + retryable=False, + ) + + class MetastoreClient(BaseHttpClient): """HTTP client for the Keboola Metastore (semantic layer repository). @@ -299,6 +334,7 @@ def post_item( *, scope: ObjectScope = _DEFAULT_SCOPE, target_project_ids: list[int] | None = None, + conflict_hint: str | None = None, ) -> dict[str, Any]: """Create an item. Returns the server's stored representation. @@ -360,7 +396,7 @@ def post_item( exc.status_code == 500 and "Failed to create meta object" in exc.message ) if is_duplicate: - raise _name_conflict(item_type, name, exc) from exc + raise _already_exists(exc, item_type, name, conflict_hint) from exc raise body = response.json() return body.get("data", body) if isinstance(body, dict) else body @@ -371,6 +407,7 @@ def put_item( item_id: str, name: str, data: dict[str, Any], + conflict_hint: str | None = None, ) -> dict[str, Any]: """Replace an item in place via ``PUT`` (revisioned update). @@ -398,7 +435,47 @@ def put_item( ) except KeboolaApiError as exc: if exc.status_code == 409: - raise _name_conflict(item_type, name, exc) from exc + raise _already_exists(exc, item_type, name, conflict_hint) from exc + raise + body = response.json() + return body.get("data", body) if isinstance(body, dict) else body + + def patch_item( + self, + item_type: SemanticType, + item_id: str, + *, + name: str | None = None, + data: dict[str, Any] | None = None, + conflict_hint: str = _PICK_ANOTHER_NAME, + ) -> dict[str, Any]: + """Partially update an item via ``PATCH`` -- only the given fields change. + + The server merges ``data`` into the stored record one TOP-LEVEL key at + a time (go-monorepo ``simpleJSONMerge``): a key in ``data`` replaces the + stored value wholesale, every key it does not name is kept, all inside + one transaction under a row lock. So unknown keys survive and a + concurrent change to another key is not overwritten -- unlike + :meth:`put_item`. Caveat: the server validates only the patched keys, + never the merged record, so the caller must validate the merged result + itself. Never sends ``scope`` (a scope step-up cannot be combined with + name/data -- :meth:`elevate_to_organization`). + + Raises ``NOT_FOUND`` on 404 and ``ALREADY_EXISTS`` on 409 (a rename to + a taken name). + """ + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if data is not None: + body["data"] = data + try: + response = self._do_request( + "PATCH", f"/api/v1/repository/{item_type}/{item_id}", json=body + ) + except KeboolaApiError as exc: + if exc.status_code == 409: + raise _already_exists(exc, item_type, name, conflict_hint) from exc raise body = response.json() return body.get("data", body) if isinstance(body, dict) else body @@ -503,3 +580,34 @@ def list_organization_items( ) body = response.json() return body.get("data", []) if isinstance(body, dict) else [] + + +@dataclass(frozen=True) +class ResolvedSchema: + """A type's JSON Schema together with the version it was resolved from (``None`` if none was).""" + + schema: dict[str, Any] + version: str | None + + +def fetch_resolved_schema(client: MetastoreClient, item_type: SemanticType) -> ResolvedSchema: + """Fetch the actual JSON Schema for a type, resolving the default version. + + Live metastore behavior (2026-07): the bare ``/api/v1/schema/{type}`` + endpoint returns only a ``{"versions": [...]}`` listing (metadata, no + schema body); the real JSON Schema lives at ``/{version}``. This resolves + ``isDefault`` (falling back to the first entry) and fetches the versioned + document. If the server someday returns the schema directly (no + ``versions`` key) it is passed through unchanged, with ``version=None``. + """ + body = client.get_schema(item_type) + versions = body.get("versions") + if not isinstance(versions, list) or not versions: + return ResolvedSchema(schema=body, version=None) + default = next((v for v in versions if v.get("isDefault")), versions[0]) + version_id = str(default.get("version", "")) + if not version_id: + return ResolvedSchema(schema=body, version=None) + return ResolvedSchema( + schema=client.get_schema(item_type, version=version_id), version=version_id + ) diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 979b98d7..c619d71a 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -358,6 +358,24 @@ "semantic-layer.reference-data.get": "read", "semantic-layer.reference-data.set": "write", "semantic-layer.reference-data.delete": "destructive", + # Row-level security (metastore) -- CLI-17. Writes are `admin` (policy authorship is an org-level + # concern); `setup` performs `create`'s write, so it carries the same class. `delete` is + # `destructive` like every remote delete; `create --scope organization` is escalated to + # `destructive` too (FLAG_ESCALATIONS). + "rls.list": "read", + "rls.detail": "read", + "rls.schema": "read", + "rls.create": "admin", + "rls.update": "admin", + "rls.delete": "destructive", + "rls.setup": "admin", + # Column-level security: same classes as rls.* for the same reason. + "cls.list": "read", + "cls.detail": "read", + "cls.schema": "read", + "cls.create": "admin", + "cls.update": "admin", + "cls.delete": "destructive", # `scope` sub-app: visibility scope / target-project grants / elevation # requests (PSGO-140). Parent key at the least-privileged level (read), # same pattern as `reference-data` above. `set --scope organization` is @@ -477,6 +495,10 @@ "semantic-layer.import --scope organization": "destructive", "semantic-layer.promote --scope organization": "destructive", "semantic-layer.build --scope organization": "destructive", + # An organization-scope policy governs its table in EVERY project of the + # organization (one invalid policy refuses all of their queries). + "rls.create --scope organization": "destructive", + "cls.create --scope organization": "destructive", } # Operations that exist ONLY on the `kbagent serve` REST surface. They are real diff --git a/src/keboola_agent_cli/server/_serve_command_map.py b/src/keboola_agent_cli/server/_serve_command_map.py index 34ef218f..7def731b 100644 --- a/src/keboola_agent_cli/server/_serve_command_map.py +++ b/src/keboola_agent_cli/server/_serve_command_map.py @@ -16,6 +16,7 @@ ("DELETE", "/agents/{task_id}"): "agent delete", ("DELETE", "/branches/{project}/metadata/{metadata_id}"): "branch metadata-delete", ("DELETE", "/branches/{project}/{branch_id}"): "branch delete", + ("DELETE", "/cls/{project}/{policy_id}"): "cls delete", ("DELETE", "/configs/{project}/{component_id}/{config_id}"): "config delete", ( "DELETE", @@ -28,6 +29,7 @@ ("DELETE", "/flows/{project}/{config_id}/schedule"): "flow schedule-remove", ("DELETE", "/notifications/{project}/{subscription_id}"): "notification delete", ("DELETE", "/projects/{alias}"): "project remove", + ("DELETE", "/rls/{project}/{policy_id}"): "rls delete", ("DELETE", "/semantic-layer/items/{kind}/{name}"): "", ( "DELETE", @@ -59,6 +61,9 @@ ("GET", "/branches/{project}/metadata"): "branch metadata-list", ("GET", "/branches/{project}/metadata/{key}"): "branch metadata-get", ("GET", "/changelog"): "changelog", + ("GET", "/cls/{project}"): "cls list", + ("GET", "/cls/{project}/schema"): "cls schema", + ("GET", "/cls/{project}/{policy_id}"): "cls detail", ("GET", "/components"): "component list", ("GET", "/components/{component_id}"): "component detail", ("GET", "/configs"): "config list", @@ -126,6 +131,9 @@ ("GET", "/projects/status"): "project status", ("GET", "/projects/{alias}/description"): "project description-get", ("GET", "/projects/{alias}/info"): "project info", + ("GET", "/rls/{project}"): "rls list", + ("GET", "/rls/{project}/schema"): "rls schema", + ("GET", "/rls/{project}/{policy_id}"): "rls detail", ("GET", "/schedules"): "schedule list", ("GET", "/schedules/find/query"): "schedule find", ("GET", "/schedules/{project}/{schedule_id}"): "schedule detail", @@ -165,10 +173,12 @@ ("GET", "/workspaces"): "workspace list", ("GET", "/workspaces/{project}/{workspace_id}"): "workspace detail", ("PATCH", "/agents/{task_id}"): "agent update", + ("PATCH", "/cls/{project}/{policy_id}"): "cls update", ("PATCH", "/configs/{project}/{component_id}/{config_id}"): "config update", ("PATCH", "/configs/{project}/{component_id}/{config_id}/rows/{row_id}"): "config row-update", ("PATCH", "/flows/{project}/{config_id}"): "flow update", ("PATCH", "/projects/{alias}"): "project edit", + ("PATCH", "/rls/{project}/{policy_id}"): "rls update", ("PATCH", "/transformations/{project}/{config_id}"): "transformation edit", ("POST", "/agents"): "agent create", ("POST", "/agents/prompt/improve"): "agent prompt-improve", @@ -182,6 +192,7 @@ ("POST", "/branches/{project}"): "branch create", ("POST", "/branches/{project}/reset"): "branch reset", ("POST", "/branches/{project}/use"): "branch use", + ("POST", "/cls/{project}"): "cls create", ("POST", "/components/{component_id}/actions/{action}"): "component sync-action", ("POST", "/components/{component_id}/scaffold"): "config new", ("POST", "/configs/{project}/{component_id}"): "config new", @@ -248,6 +259,7 @@ ("POST", "/projects"): "project add", ("POST", "/projects/bulk-delete"): "project remove", ("POST", "/projects/use/{alias}"): "project use", + ("POST", "/rls/{project}"): "rls create", ("POST", "/semantic-layer/build"): "semantic-layer build", ("POST", "/semantic-layer/diff"): "semantic-layer diff", ("POST", "/semantic-layer/import"): "semantic-layer import", diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index 5cd736bb..920cc0bd 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -52,6 +52,7 @@ auth, billing, branches, + cls, components, configs, data_apps, @@ -69,6 +70,7 @@ notifications, org, projects, + rls, schedules, search, semantic_layer, @@ -346,6 +348,29 @@ "Mirrors `kbagent semantic-layer *`." ), }, + { + "name": "rls", + "description": ( + "**Development.** " + "Row-level security policies (metastore-backed `rls-policy` " + "objects), authored at `targeted` scope unless `organization` is " + "asked for -- never `project` scope. Mirrors `kbagent rls list|detail|schema|" + "create|update|delete` (`rls setup`'s interactive picker has no " + "endpoint of its own, same carve-out as " + "`auth register-projects`)." + ), + }, + { + "name": "cls", + "description": ( + "**Development.** " + "Column-level security policies (metastore-backed `cls-policy` " + "objects: a per-principal `visible_columns` allowlist), authored " + "at `targeted` scope unless `organization` is asked for -- never " + "`project` scope. Mirrors `kbagent cls list|detail|schema|create|update|" + "delete`." + ), + }, # ---- AI & Tools ---- { "name": "kai", @@ -1034,6 +1059,8 @@ async def _generic_handler(_request, exc: Exception): app.include_router(encrypt.router) app.include_router(search.router) app.include_router(semantic_layer.router) + app.include_router(rls.router) + app.include_router(cls.router) app.include_router(transformation.router) app.include_router(docs.router) app.include_router(org.router) diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index 7cba578b..e61e90f4 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -22,6 +22,7 @@ from ..services.auth_service import AuthService from ..services.billing_service import BillingService from ..services.branch_service import BranchService +from ..services.cls_service import ClsService from ..services.component_service import ComponentService from ..services.config_service import ConfigService from ..services.data_app_git_service import DataAppGitService @@ -42,6 +43,7 @@ from ..services.org_service import OrgService from ..services.project_service import ProjectService from ..services.repo_validate_service import RepoValidateService +from ..services.rls_service import RlsService from ..services.schedule_service import ScheduleService from ..services.search_service import SearchService from ..services.semantic_layer_service import SemanticLayerService @@ -118,6 +120,8 @@ class ServiceRegistry: data_app_git: DataAppGitService = field(init=False) dev_portal: DeveloperPortalService = field(init=False) semantic_layer: SemanticLayerService = field(init=False) + rls: RlsService = field(init=False) + cls: ClsService = field(init=False) repo_validate: RepoValidateService = field(init=False) kai: KaiService = field(init=False) encrypt: EncryptService = field(init=False) @@ -164,6 +168,8 @@ def __post_init__(self) -> None: # validate --deep + add dataset --deep-fields + build) and an # optional metastore_client_factory; the defaults work for both. self.semantic_layer = SemanticLayerService(config_store=cs) + self.rls = RlsService(config_store=cs) + self.cls = ClsService(config_store=cs) self.repo_validate = RepoValidateService(config_store=cs) self.kai = KaiService(config_store=cs) self.encrypt = EncryptService(config_store=cs) diff --git a/src/keboola_agent_cli/server/routers/cls.py b/src/keboola_agent_cli/server/routers/cls.py new file mode 100644 index 00000000..82b70a8f --- /dev/null +++ b/src/keboola_agent_cli/server/routers/cls.py @@ -0,0 +1,12 @@ +"""Column-level security endpoints -- the REST mirror of ``kbagent cls *``. + +Built by :func:`.rls.build_policy_router`: same routes, permission gates and +bodies as the ``rls`` router, over the ``cls-policy`` object type (each rule +is ``{principal|principals, visible_columns}``, validated by the service). +""" + +from __future__ import annotations + +from .rls import build_policy_router + +router = build_policy_router("cls") diff --git a/src/keboola_agent_cli/server/routers/rls.py b/src/keboola_agent_cli/server/routers/rls.py new file mode 100644 index 00000000..f2ddc717 --- /dev/null +++ b/src/keboola_agent_cli/server/routers/rls.py @@ -0,0 +1,182 @@ +"""Row-level security endpoints -- the REST mirror of ``kbagent rls *``. + +1:1 with the CLI group (CONTRIBUTING: every non-terminal command has a +route), except ``rls setup``: it is a genuinely terminal-only interactive +wizard (a checkbox table picker + guided condition prompts), the same +carve-out ``auth register-projects``' picker uses. Its data source (listing +a project's tables) is already covered by the existing ``storage`` router; +its write (create one policy per selected table) is the same +``POST /rls/{project}`` route below. + +:func:`build_policy_router` also builds the ``cls`` router: the two groups +differ only in the service and the rule shape, which the service validates. + +**Every route enforces the permission policy** (``Depends(require_permission)``) +-- RLS is security-sensitive enough (admin-class writes) that +CLI-gates-but-REST-doesn't would be a real hole, mirroring +``merge_requests.py`` rather than the (ungated) ``notifications.py``. A +create at ``organization`` scope is also checked as +``.create --scope organization`` (destructive), like the CLI flag. + +The ``GET /{project}/schema`` route is registered before +``GET /{project}/{policy_id}`` so ``schema`` is never shadowed by the +parameterized route -- Starlette matches path operations in registration +order. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from ...errors import ErrorCode, KeboolaApiError +from ...permissions import PermissionEngine +from ...services._rls_condition import Dialect +from ...services.rls_service import PolicyScope +from ..dependencies import ( + ServiceRegistry, + get_permission_engine, + get_registry, + require_permission, +) + +# -- Bodies -------------------------------------------------------------------------------------- + + +class PolicyCreate(BaseModel): + """``target_projects`` takes aliases or project IDs, like the semantic-layer bodies. + + ``target_projects`` with ``scope: organization`` is refused by the service (400). + """ + + table_id: str + rules: list[dict[str, Any]] + dialect: Dialect | None = None # None = the project backend + default: dict[str, Any] | None = None # rls only: condition for identities no rule matches + scope: PolicyScope = PolicyScope.TARGETED + target_projects: list[str | int] | None = None + dry_run: bool = False + + +class PolicyUpdate(BaseModel): + """Partial update: an omitted field is unchanged; ``target_projects: []`` revokes every grant.""" + + table_id: str | None = None + dialect: Dialect | None = None + rules: list[dict[str, Any]] | None = None + default: dict[str, Any] | None = None # rls only; omitted = unchanged + target_projects: list[str | int] | None = None + dry_run: bool = False + + +def build_policy_router(group: Literal["rls", "cls"]) -> APIRouter: + """The ``/rls`` or ``/cls`` router.""" + label = group.upper() + router = APIRouter(prefix=f"/{group}", tags=[group]) + + def perm(operation: str) -> Any: + return Depends(require_permission(f"{group}.{operation}")) + + def service(registry: ServiceRegistry) -> Any: + return getattr(registry, group) + + @router.get("/{project}", summary=f"List {label} policies", dependencies=[perm("list")]) + def list_policies( + project: str, registry: ServiceRegistry = Depends(get_registry) + ) -> dict[str, Any]: + """Policies visible to a project. Mirrors `kbagent list`.""" + return service(registry).list_policies(project) + + @router.get( + "/{project}/schema", + summary=f"Fetch the live {group}-policy JSON Schema", + dependencies=[perm("schema")], + ) + def get_schema( + project: str, registry: ServiceRegistry = Depends(get_registry) + ) -> dict[str, Any]: + """Live schema from the metastore -- no offline bundled snapshot.""" + fetch = service(registry).fetch_schema(project) + if fetch.schema is None: + raise KeboolaApiError( + message=f"Could not fetch the {group}-policy schema: {fetch.reason}", + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + return {"format": "json-schema", "source": "live", "schema": fetch.schema} + + @router.get( + "/{project}/{policy_id}", summary=f"Get one {label} policy", dependencies=[perm("detail")] + ) + def get_policy( + project: str, policy_id: str, registry: ServiceRegistry = Depends(get_registry) + ) -> dict[str, Any]: + """Full rule set for one policy. Mirrors `kbagent detail`.""" + return service(registry).get_policy(project, policy_id) + + @router.post("/{project}", summary=f"Create a {label} policy", dependencies=[perm("create")]) + def create_policy( + project: str, + body: PolicyCreate, + registry: ServiceRegistry = Depends(get_registry), + engine: PermissionEngine = Depends(get_permission_engine), + ) -> dict[str, Any]: + """Create one policy for one table at `targeted` (default) or `organization` scope. + + Never `project` scope (the schema does not support it). Mirrors `kbagent create`. + """ + if body.scope == PolicyScope.ORGANIZATION: + engine.check_or_raise(f"{group}.create --scope organization") + return service(registry).create_policy( + project, + table=body.table_id, + rules=body.rules, + dialect=body.dialect, + default=body.default, + scope=body.scope, + target_projects=body.target_projects, + dry_run=body.dry_run, + ) + + @router.patch( + "/{project}/{policy_id}", summary=f"Update a {label} policy", dependencies=[perm("update")] + ) + def update_policy( + project: str, + policy_id: str, + body: PolicyUpdate, + registry: ServiceRegistry = Depends(get_registry), + ) -> dict[str, Any]: + """Partial update: an omitted field keeps its current value. Mirrors `kbagent update`.""" + return service(registry).update_policy( + project, + policy_id, + table=body.table_id, + dialect=body.dialect, + rules=body.rules, + default=body.default, + target_projects=body.target_projects, + dry_run=body.dry_run, + ) + + @router.delete( + "/{project}/{policy_id}", + summary=f"Delete a {label} policy", + dependencies=[perm("delete")], + ) + def delete_policy( + project: str, + policy_id: str, + dry_run: bool = False, + registry: ServiceRegistry = Depends(get_registry), + ) -> dict[str, Any]: + """Delete a policy (``?dry_run=true`` only shows it). Mirrors `kbagent delete`.""" + return service(registry).delete_policy(project, policy_id, dry_run=dry_run) + + return router + + +router = build_policy_router("rls") diff --git a/src/keboola_agent_cli/services/_rls_condition.py b/src/keboola_agent_cli/services/_rls_condition.py new file mode 100644 index 00000000..ef6b9b9c --- /dev/null +++ b/src/keboola_agent_cli/services/_rls_condition.py @@ -0,0 +1,326 @@ +"""RLS condition primitives: shape constants, structural validation, preview. + +Pure functions: no HTTP, no ``ConfigStore`` -- trivially unit-testable, +mirroring ``flow_validation.py``'s split for the same reason. + +The condition/rules shape mirrored here is the one defined in +``keboola-mcp-server``'s RFC (``feature_spec/rls_query_tool/RFC.md``, "Rule +storage" section) -- kbagent never invents its own shape, it authors exactly +what the enforcement engine (that repo's ``rls.py::_compile_primitive``, +sqlglot-based) expects to read back from the metastore. The checks follow the policy +schema 1.1.0 (go-monorepo ``rls-policy_schema_1.1.0.json``): a rule selects identities by +``principal``, ``principals`` or IdP ``groups``; within a policy every rule matching one identity +applies (the conditions combine with OR; policies on one table combine with AND); ``{"false": true}`` matches no row; a literal may be the +``{"$identity": ...}`` placeholder the engine resolves per reader. + +``compile_condition_preview`` below is a **preview** for ``--dry-run`` / +confirmation display, not the enforcement engine -- that lives in +``keboola-mcp-server`` (sqlglot-based, a different repo). It follows the +enforcement's rendering where an admin could be misled otherwise: columns +quoted per dialect (the enforcement matches them case-exactly), booleans as +``TRUE``/``FALSE``. ``null`` comparisons never reach it: the enforcement would +render ``col = NULL`` (matches nothing), so validation refuses them. ``$identity`` +placeholders render as ```` / ``()``. Adding +sqlglot as a kbagent dependency just for this string is not warranted. +""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Any + +# The condition operators of the policy schema, checked locally so a typo gets a specific error +# even when the live schema is unavailable. COMPARISON_SQL also renders the preview. +COMPARISON_SQL = {"eq": "=", "ne": "!=", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} +RLS_COMPARISON_OPS = frozenset(COMPARISON_SQL) +RLS_MEMBERSHIP_OPS = frozenset({"in", "not_in"}) +RLS_NULLNESS_OPS = frozenset({"is_null", "is_not_null"}) +RLS_CONDITION_OPS = RLS_COMPARISON_OPS | RLS_MEMBERSHIP_OPS | RLS_NULLNESS_OPS + + +# The metastore's `rls-policy` schema (RFC) restricts `dialect` to these two +# -- the same pair `rewrite_query()` on the enforcement side never transpiles +# between (predicates are pinned to one workspace dialect per policy). +class Dialect(StrEnum): + """The policy schema's ``dialect`` enum (the CLI ``--dialect`` choice and the REST field).""" + + SNOWFLAKE = "snowflake" + BIGQUERY = "bigquery" + + +RLS_DIALECTS: tuple[str, ...] = tuple(Dialect) + +# The enforcement's principal pattern (mcp-server ``rls.py``): no whitespace or control characters. +_PRINCIPAL_RE = re.compile(r"^[^\s\x00-\x1f\x7f]+$") +# IdP group names may contain spaces ("Sales EU"); only control characters are refused. +_GROUP_RE = re.compile(r"^[^\x00-\x1f\x7f]+$") + +# The rule selectors of schema 1.1.0 -- each rule names exactly one. +SELECTORS: tuple[str, ...] = ("principal", "principals", "groups") + +# Schema 1.1.0 wildcard selector: matches every IDENTIFIED reader (never an anonymous one). It must be the +# only entry of a `principals` / `groups` list, and exists only from schema 1.1.0: in a 1.0.0 object it is a +# literal that no email equals, so it matches nobody. +WILDCARD = "*" +WILDCARD_MIN_SCHEMA_VERSION = (1, 1, 0) + +# The `$identity` placeholders: `value` may be the reader's email, `values` the reader's groups. +IDENTITY_EMAIL = {"$identity": "email"} +IDENTITY_GROUPS = {"$identity": "groups"} + +# How each dialect quotes an identifier. The enforcement compiles columns quoted (case-exact), so the +# preview must too -- an unquoted preview would hide a case mismatch the real filter does not forgive. +_QUOTE = {"snowflake": '"', "bigquery": "`"} + + +def _is_sentinel(condition: dict[Any, Any], key: str) -> bool: + """Exactly ``{"true": true}`` / ``{"false": true}``. ``{"true": false}`` (or ``0``/extra keys) would + otherwise read as the opposite of what the author typed. ``is True`` because ``1 == True``.""" + return len(condition) == 1 and condition[key] is True + + +def _is_scalar(value: Any) -> bool: + """A literal the primitives allow: string, number or boolean -- never an object or array.""" + return isinstance(value, str | int | float | bool) + + +def validate_condition_ops(condition: Any) -> list[str]: + """Recursively check every operator name used in ``condition`` is known. + + A defensive pre-check ahead of the (possibly unavailable) live schema + fetch: an unknown ``op`` typo gets a specific error here instead of a + generic Draft7 "not valid under any of the given schemas" once it does + reach structural validation. Returns human-readable error strings. + """ + errors: list[str] = [] + if not isinstance(condition, dict): + return [f"condition must be an object, got {type(condition).__name__}"] + for sentinel in ("true", "false"): + if sentinel in condition: + if not _is_sentinel(condition, sentinel): + errors.append(f"'{sentinel}' condition must be exactly {{\"{sentinel}\": true}}") + return errors + if "and" in condition or "or" in condition: + # Exactly one composition key and nothing else: `{"and": [...], "or": [...]}` would otherwise have + # one branch silently validated and the other dropped, so the policy would no longer be the tree + # the author submitted. + if set(condition) not in ({"and"}, {"or"}): + errors.append( + f"an 'and'/'or' condition must have exactly one of those keys and no others, got {sorted(condition)}" + ) + return errors + key = "and" if "and" in condition else "or" + clauses = condition[key] + if not isinstance(clauses, list) or len(clauses) < 2: + errors.append(f"'{key}' requires at least 2 nested conditions") + return errors + for clause in clauses: + errors.extend(validate_condition_ops(clause)) + return errors + op = condition.get("op") + if not isinstance(op, str) or op not in RLS_CONDITION_OPS: + errors.append(f"unknown condition op {op!r} (expected one of {sorted(RLS_CONDITION_OPS)})") + return errors + # The primitive's shape: checked here so a malformed rule fails as INVALID_RLS_POLICY even when the + # live schema is unavailable, instead of reaching `compile_condition_preview` and crashing it. + column = condition.get("column") + if not isinstance(column, str) or not column: + errors.append(f"condition with op {op!r} needs a non-empty string 'column'") + if op in RLS_COMPARISON_OPS: + if "value" not in condition: + errors.append(f"condition with op {op!r} needs a 'value'") + elif condition["value"] is None: + # The enforcement renders `col = NULL`, which matches no row: the principal silently sees nothing. + errors.append( + f"condition with op {op!r} cannot compare to null; use op 'is_null' / 'is_not_null'" + ) + elif not _is_scalar(condition["value"]) and condition["value"] != IDENTITY_EMAIL: + errors.append( + f"condition with op {op!r} needs a string, number or boolean 'value', " + f"or {IDENTITY_EMAIL}" + ) + if op in RLS_MEMBERSHIP_OPS: + values = condition.get("values") + if values == IDENTITY_GROUPS: + pass + elif not isinstance(values, list) or not values: + errors.append( + f"condition with op {op!r} needs a non-empty list 'values', or {IDENTITY_GROUPS}" + ) + elif any(value is None for value in values): + errors.append( + f"condition with op {op!r} cannot list null in 'values' (it never matches); use 'is_null'" + ) + elif not all(_is_scalar(value) for value in values): + errors.append(f"condition with op {op!r} needs string, number or boolean 'values'") + # No keys beyond the op's own shape (the schema forbids additional properties): a stray `values` on an + # `eq`, or a `value` on an `in`, is an authoring mistake that would otherwise be silently ignored. + allowed = {"column", "op"} | ( + {"value"} if op in RLS_COMPARISON_OPS else {"values"} if op in RLS_MEMBERSHIP_OPS else set() + ) + if extra := sorted(set(condition) - allowed): + errors.append(f"condition with op {op!r} has unexpected keys {extra}") + return errors + + +def validate_principal_fields(rule: dict[Any, Any], index: int) -> list[str]: + """Each rule names exactly one selector: ``principal``, ``principals`` or ``groups``. + + Shared by the RLS and CLS (``cls_service``) local checks -- both policy types use the identical + selector shape. Key PRESENCE decides "exactly one" (a truthiness test would let + ``{"principal": "a", "principals": []}`` through); the value is then checked on its own. + """ + present = [key for key in SELECTORS if key in rule] + if len(present) != 1: + return [f"rules[{index}] must set exactly one of {'/'.join(SELECTORS)}"] + key = present[0] + names = [rule[key]] if key == "principal" else rule[key] + if ( + not isinstance(names, list) + or not names + or not all(isinstance(name, str) and name for name in names) + ): + shape = ( + "a non-empty string" if key == "principal" else "a non-empty list of non-empty strings" + ) + return [f"rules[{index}].{key} must be {shape}"] + if key != "principal" and WILDCARD in names and len(names) > 1: + return [ + f"rules[{index}].{key}: {WILDCARD!r} (every identified reader) must be the only entry" + ] + pattern, problem = ( + (_GROUP_RE, "control characters") + if key == "groups" + else (_PRINCIPAL_RE, "whitespace or control characters") + ) + return [ + f"rules[{index}]: {key} entry {name!r} contains {problem}" + for name in names + if not pattern.fullmatch(name) + ] + + +def rules_use_wildcard(rules: Any) -> bool: + """Whether any rule selects by the ``"*"`` wildcard (``principal``, ``principals`` or ``groups``).""" + if not isinstance(rules, list): + return False + return any( + isinstance(rule, dict) + and any( + (rule[key] == WILDCARD) if key == "principal" else (WILDCARD in (rule[key] or [])) + for key in SELECTORS + if key in rule and isinstance(rule[key], str | list) + ) + for rule in rules + ) + + +def schema_lacks_wildcard(schema: dict[str, Any]) -> str | None: + """The schema's ``version`` when it is older than the one that defines ``"*"``, else ``None``. + + An unreadable or absent ``version`` is not proof of anything, so it returns ``None`` (the caller's + structural validation against the live schema still runs). + """ + version = schema.get("version") + try: + parsed = tuple(int(part) for part in str(version).split(".")[:3]) + except ValueError: + return None + return str(version) if parsed < WILDCARD_MIN_SCHEMA_VERSION else None + + +def rule_selector(rule: dict[str, Any]) -> dict[str, Any]: + """The rule's selector as written (``{"principal": ...}``, ``{"principals": [...]}`` or ``{"groups": [...]}``).""" + return {key: rule[key] for key in SELECTORS if key in rule} + + +def validate_rules_local(rules: Any) -> list[str]: + """Semantic checks on ``rules`` that hold regardless of schema availability. + + Runs even when the live schema fetch degraded (see ``RlsService._fetch_schema``): each rule names + exactly one selector (:func:`validate_principal_fields`) and a well-formed ``condition`` + (:func:`validate_condition_ops`). Rules naming the same identity are fine -- the engine ORs them. + Returns human-readable error strings (empty = valid). + """ + errors: list[str] = [] + if not isinstance(rules, list) or not rules: + return ["'rules' must be a non-empty array"] + for index, rule in enumerate(rules): + if not isinstance(rule, dict): + errors.append(f"rules[{index}] must be an object") + continue + errors.extend(validate_principal_fields(rule, index)) + condition = rule.get("condition") + if condition is None: + errors.append(f"rules[{index}] is missing 'condition'") + continue + errors.extend(f"rules[{index}].{err}" for err in validate_condition_ops(condition)) + return errors + + +def compile_condition_preview(condition: dict[str, Any], dialect: str) -> str: + """Render ``condition`` as a human-readable ``WHERE``-clause-shaped string. + + Preview only -- see module docstring. Raises ``ValueError`` on a + condition shape this renderer doesn't recognize (callers should already + have run it through :func:`validate_condition_ops` / structural + validation first; this function stays strict rather than silently + rendering something misleading). + """ + for sentinel in ("true", "false"): + if sentinel in condition: + if not _is_sentinel(condition, sentinel): + raise ValueError( + f"'{sentinel}' condition must be exactly {{\"{sentinel}\": true}}: {condition!r}" + ) + return sentinel.upper() + if "and" in condition or "or" in condition: + if len(condition) != 1: + raise ValueError( + f"an 'and'/'or' condition must have exactly one of those keys and no others: {condition!r}" + ) + if "and" in condition: + return " AND ".join( + f"({compile_condition_preview(c, dialect)})" for c in condition["and"] + ) + return " OR ".join(f"({compile_condition_preview(c, dialect)})" for c in condition["or"]) + + column = condition.get("column") + op = condition.get("op") + if not column or op is None: + raise ValueError(f"unrecognized condition shape: {condition!r}") + + quote = _QUOTE.get(dialect, '"') + column = f"{quote}{column}{quote}" + if op in COMPARISON_SQL: + return f"{column} {COMPARISON_SQL[op]} {_preview_literal(condition.get('value'))}" + if op in RLS_MEMBERSHIP_OPS: + values = condition.get("values") or [] + rendered = ( + "" + if values == IDENTITY_GROUPS + else ", ".join(_preview_literal(v) for v in values) + ) + keyword = "IN" if op == "in" else "NOT IN" + return f"{column} {keyword} ({rendered})" + if op == "is_null": + return f"{column} IS NULL" + if op == "is_not_null": + return f"{column} IS NOT NULL" + raise ValueError(f"unrecognized condition op: {op!r}") + + +def _preview_literal(value: Any) -> str: + """Render one scalar literal for the preview string. Not SQL-injection-safe + + on purpose -- this output is never executed, only displayed for human + review (see module docstring). + """ + if value == IDENTITY_EMAIL: + return "" + if isinstance(value, str): + return "'" + value.replace("'", "''") + "'" + if isinstance(value, bool): # before the generic str(): Python's `True` is SQL's TRUE + return "TRUE" if value else "FALSE" + return str(value) diff --git a/src/keboola_agent_cli/services/_semantic_layer_scope.py b/src/keboola_agent_cli/services/_semantic_layer_scope.py index a200b9ca..a30d7743 100644 --- a/src/keboola_agent_cli/services/_semantic_layer_scope.py +++ b/src/keboola_agent_cli/services/_semantic_layer_scope.py @@ -53,11 +53,12 @@ def resolve_target_project_ids( for target in parse_target_projects(targets): project = config_store.get_project(target) if project is None: - if not target.isdigit(): + # isascii(): `str.isdigit()` is also true for e.g. '²', which `int()` then rejects. + if not (target.isascii() and target.isdigit()) or int(target) == 0: raise KeboolaApiError( message=( f"--target-project {target!r} is neither a registered project alias " - "nor a numeric project ID. See `kbagent project list`." + "nor a positive numeric project ID. See `kbagent project list`." ), error_code=ErrorCode.INVALID_ARGUMENT, ) diff --git a/src/keboola_agent_cli/services/cls_service.py b/src/keboola_agent_cli/services/cls_service.py new file mode 100644 index 00000000..c62a35f1 --- /dev/null +++ b/src/keboola_agent_cli/services/cls_service.py @@ -0,0 +1,73 @@ +"""Column-level security service -- business logic for ``kbagent cls``. + +The ``cls-policy`` metastore object is the column-level sibling of +``rls-policy``: one object per protected table, ``{table, dialect, rules}``, +where each rule selects identities (``principal``, ``principals`` or IdP +``groups``) and the columns they may see (``visible_columns`` -- an allowlist +projection; masking is not supported). Under schema 1.1.0 an identity several +rules match sees the union of their columns; there is no ``default``. + +Everything except the rule shape is identical to :class:`RlsService` (never +``project`` scope, partial (PATCH) update, live-schema validation that +degrades to a warning), so this class only overrides the policy type and the +rule / default hooks. Enforcement happens in ``keboola-mcp-server``'s +``query_data``, not here -- kbagent only authors policies. +""" + +from __future__ import annotations + +import re +from typing import Any + +from ..errors import ErrorCode +from ..metastore_client import SemanticType +from . import _rls_condition +from .rls_service import RlsService + +CLS_ITEM_TYPE: SemanticType = "cls-policy" + +__all__ = ["CLS_ITEM_TYPE", "ClsService"] + +# Same identifier pattern the ``cls-policy`` schema enforces on ``visible_columns`` entries. +_COLUMN_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +class ClsService(RlsService): + """Business logic for the ``cls`` command group.""" + + item_type: SemanticType = CLS_ITEM_TYPE + label = "CLS" + invalid_error_code = ErrorCode.INVALID_CLS_POLICY + + def _local_rule_errors(self, rules: Any) -> list[str]: + """Principal shape + a non-empty identifier allowlist per rule (schema-independent).""" + if not isinstance(rules, list) or not rules: + return ["'rules' must be a non-empty array"] + errors: list[str] = [] + for index, rule in enumerate(rules): + if not isinstance(rule, dict): + errors.append(f"rules[{index}] must be an object") + continue + errors.extend(_rls_condition.validate_principal_fields(rule, index)) + columns = rule.get("visible_columns") + if not isinstance(columns, list) or not columns: + errors.append(f"rules[{index}] needs a non-empty 'visible_columns' array") + continue + errors.extend( + f"rules[{index}].visible_columns: {col!r} is not a valid column name" + for col in columns + if not isinstance(col, str) or not _COLUMN_RE.fullmatch(col) + ) + return errors + + def _preview_rules(self, rules: list[dict[str, Any]], dialect: str) -> list[dict[str, Any]]: + """Show each selector's allowed projection (``dialect`` is unused: no SQL is rendered).""" + return [ + {**_rls_condition.rule_selector(rule), "visible_columns": rule["visible_columns"]} + for rule in rules + ] + + @staticmethod + def _default_errors(default: Any) -> list[str]: + """A ``cls-policy`` has no ``default``: an identity no rule matches is refused.""" + return [] if default is None else ["'default' is not supported by a CLS policy"] diff --git a/src/keboola_agent_cli/services/flow_validation.py b/src/keboola_agent_cli/services/flow_validation.py index 81eda845..ddd387c4 100644 --- a/src/keboola_agent_cli/services/flow_validation.py +++ b/src/keboola_agent_cli/services/flow_validation.py @@ -15,7 +15,7 @@ from collections import deque from typing import Any -import jsonschema +from ..json_utils import draft7_errors # Operand arity per operator (semantic; the schema cannot express these counts). _BINARY_OPERATORS = frozenset( @@ -30,13 +30,7 @@ def _structural_errors( phases: list[dict[str, Any]], tasks: list[dict[str, Any]], schema: dict[str, Any] ) -> list[str]: """Run Draft7 validation against the supplied schema, collecting ALL errors.""" - document = {"phases": phases, "tasks": tasks} - validator = jsonschema.Draft7Validator(schema) - errors: list[str] = [] - for err in sorted(validator.iter_errors(document), key=lambda e: list(e.path)): - path = "/".join(str(p) for p in err.path) or "(root)" - errors.append(f"Schema error at {path}: {err.message}") - return errors + return draft7_errors({"phases": phases, "tasks": tasks}, schema) def _condition_arity_errors(condition: Any) -> list[str]: diff --git a/src/keboola_agent_cli/services/rls_service.py b/src/keboola_agent_cli/services/rls_service.py new file mode 100644 index 00000000..35bd2e2d --- /dev/null +++ b/src/keboola_agent_cli/services/rls_service.py @@ -0,0 +1,515 @@ +"""Row-level security service -- business logic for ``kbagent rls``. + +Composes :class:`MetastoreClient` primitives into the ``rls-policy`` CRUD +operations exposed by the CLI, mirroring :class:`SemanticLayerService`'s +shape for the same kind of metastore-backed object type -- but scaled down +to one object type instead of seven. + +The one structural rule every write path in this module enforces: an +``rls-policy`` object is **never** created at plain ``project`` scope (the +metastore schema supports only ``organization`` and ``targeted``). The +default is ``targeted`` -- the schema's own default: the policy governs the +owning project plus the target projects it is granted to. ``organization`` +governs the table in EVERY project of the organization, so it is only ever +an explicit choice (RFC: "org-wide scope is a deliberate choice an admin +makes, not a default"). + +Who may write (metastore schema ACL): a project admin (master token with the +admin role) may create/update/delete ``targeted`` policies of its own project +WITHOUT target projects; granting target projects and ``organization`` scope +need the organization-admin role. + +Policy schema 1.1.0 (the metastore default): a rule selects identities by +``principal``, ``principals`` or IdP ``groups``; within a policy every rule +matching one identity applies and the conditions combine with OR (so one +identity in several rules is fine); several policies on one table combine +with AND, so an added policy can only narrow access; an RLS ``default`` +covers an identified reader no rule matches (absent = the read is refused). A dialect other than the project +backend makes the enforcement refuse reads of that table, so it is refused +here before any write. + +On a stack whose metastore predates the ``rls-policy`` schema, a real project +answers with a schema-fetch/list/get/post failure (see ``fetch_schema`` and the +``gotchas.md`` entry). +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, NoReturn, cast + +import jsonschema + +from ..config_store import ConfigStore +from ..errors import ErrorCode, KeboolaApiError +from ..json_utils import draft7_errors +from ..metastore_client import ( + MetastoreClient, + ObjectScope, + SemanticType, + fetch_resolved_schema, +) +from ..models import ProjectConfig +from . import _rls_condition +from ._rls_condition import RLS_DIALECTS +from ._semantic_layer_scope import resolve_target_project_ids +from .base import BaseService, ClientFactory, make_session_aware_client_factory + +RLS_ITEM_TYPE: SemanticType = "rls-policy" + +__all__ = ["RLS_ITEM_TYPE", "PolicyScope", "RlsSchemaFetch", "RlsService"] + +MetastoreClientFactory = Callable[[str, str], MetastoreClient] + +# Failures of the caller's own credentials/permissions: never to be mistaken for "schema not available". +_AUTH_ERROR_CODES = frozenset( + { + ErrorCode.INVALID_TOKEN, + ErrorCode.MISSING_MASTER_TOKEN, + ErrorCode.ACCESS_DENIED, + ErrorCode.PERMISSION_DENIED, + } +) + + +class PolicyScope(StrEnum): + """The scopes a policy may be authored at (the schema's ``x-metastore.scope.supported``).""" + + TARGETED = "targeted" + ORGANIZATION = "organization" + + +@dataclass(frozen=True) +class PolicyValidation: + """Outcome of :meth:`RlsService._validate_policy`: blocking ``errors`` and non-blocking ``warnings``.""" + + errors: list[str] + warnings: list[str] + + +@dataclass(frozen=True) +class RlsSchemaFetch: + """The live policy schema, or ``schema=None`` + ``reason`` when it is unavailable (never blocks).""" + + schema: dict[str, Any] | None + reason: str | None + + +class RlsService(BaseService): + """Business logic for the ``rls`` command group. + + :class:`ClsService` overrides only ``item_type`` / ``label`` / ``invalid_error_code`` and the two + rule hooks (:meth:`_local_rule_errors`, :meth:`_preview_rules`). + """ + + item_type: SemanticType = RLS_ITEM_TYPE + label = "RLS" + invalid_error_code = ErrorCode.INVALID_RLS_POLICY + + def __init__( + self, + config_store: ConfigStore, + client_factory: ClientFactory | None = None, + metastore_client_factory: MetastoreClientFactory | None = None, + ) -> None: + super().__init__(config_store=config_store, client_factory=client_factory) + self._metastore_factory: MetastoreClientFactory = ( + metastore_client_factory + or make_session_aware_client_factory(config_store, MetastoreClient) + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_one_project(self, alias: str) -> ProjectConfig: + return self.resolve_projects([alias])[alias] + + def _new_metastore_client(self, project: ProjectConfig) -> MetastoreClient: + """Build a fresh metastore client. Caller is responsible for ``close()``.""" + return self._metastore_factory(project.stack_url, project.token) + + def fetch_schema(self, alias: str) -> RlsSchemaFetch: + """The live schema (``rls schema``); writes reuse their open client instead.""" + project = self._resolve_one_project(alias) + with self._new_metastore_client(project) as client: + return self._fetch_schema(client) + + def _fetch_schema(self, client: MetastoreClient) -> RlsSchemaFetch: + """Fetch the live policy schema, degrading when it is merely *unavailable*. + + A network error, an empty/malformed schema or a non-auth ``KeboolaApiError`` (most likely: the + object type isn't registered on the stack's metastore) degrades to ``schema=None`` + ``reason`` + and never blocks a caller. An authentication/authorization failure (``INVALID_TOKEN``, + ``MISSING_MASTER_TOKEN``, access denied) is a real problem the user must see, so it is + re-raised instead of being reported as a missing schema. + """ + try: + resolved = fetch_resolved_schema(client, self.item_type) + schema = resolved.schema + except KeboolaApiError as exc: + if exc.error_code in _AUTH_ERROR_CODES or exc.status_code in (401, 403): + raise + return RlsSchemaFetch(schema=None, reason=exc.message) + except Exception as exc: # any fetch failure must degrade, never block a write + return RlsSchemaFetch(schema=None, reason=str(exc)) + if not schema: + return RlsSchemaFetch( + schema=None, reason=f"metastore returned no schema for {self.item_type}" + ) + if resolved.version is None and isinstance(schema.get("versions"), list): + # `fetch_resolved_schema` hands back the bare version listing when it cannot pick a version + # (e.g. an empty listing). It is a valid, constraint-free "schema", so validating against it + # would silently skip every structural check -- treat it as unavailable instead. + return RlsSchemaFetch( + schema=None, + reason=f"the metastore returned only a version listing, no schema, for {self.item_type}", + ) + try: + jsonschema.Draft7Validator.check_schema(schema) + except jsonschema.SchemaError as exc: + # A non-empty but malformed schema would make the structural validation raise a traceback; + # report it as unavailable instead (same as an empty one). + return RlsSchemaFetch( + schema=None, + reason=f"the {self.item_type} schema from the metastore is malformed: {exc.message}", + ) + return RlsSchemaFetch(schema=schema, reason=None) + + @staticmethod + def _row_from_item(item: dict[str, Any]) -> dict[str, Any]: + attrs = item.get("attributes") or {} + meta = item.get("meta") or {} + return { + "id": item.get("id", ""), + "table": attrs.get("table", ""), + "dialect": attrs.get("dialect", ""), + "rule_count": len(attrs.get("rules") or []), + "scope": meta.get("scope", ""), + # The owning project; the metastore omits it for an organization-scope policy. + "owner_project_id": meta.get("projectId"), + "source_project_id": meta.get("sourceProjectId"), + "target_project_ids": meta.get("targetProjectIds") or [], + } + + def _project_dialect(self, project: ProjectConfig) -> str: + """The project's backend -- the only dialect the enforcement accepts for its policies.""" + with self._client_factory(project.stack_url, project.token) as client: + backend = (client.verify_token().default_backend or "").lower() + if backend not in RLS_DIALECTS: + self._raise_invalid( + [ + f"the project backend is {backend or 'unknown'!r}; policies support {RLS_DIALECTS} only" + ] + ) + return backend + + def _resolve_targets(self, alias: str, targets: Sequence[int | str] | None) -> list[int]: + """``--target-project`` values (alias or project ID, comma lists) -> unique project IDs.""" + return resolve_target_project_ids( + self._config_store, alias, [str(t) for t in targets or []] + ) + + def _validate_policy( + self, + client: MetastoreClient, + *, + policy: dict[str, Any], + backend: str, + ) -> PolicyValidation: + """Run every check this module can on the WHOLE policy, local checks first. + + Local (schema-independent) checks always run: the dialect matches the project backend, and + the rules (and an RLS ``default``) are well-formed. Structural (Draft7, against the live schema) checks run when the + fetch succeeds -- when it doesn't, the caller gets a warning, not a block (see + :class:`RlsSchemaFetch`). The whole policy is validated even for a partial update, because the + metastore's PATCH validates only the keys it receives. + """ + dialect = policy["dialect"] + errors: list[str] = [] + if dialect != backend: + errors.append( + f"dialect {dialect!r} does not match the project backend {backend!r} " + "(the enforcement would refuse every read of this table)" + ) + errors.extend(self._local_rule_errors(policy["rules"])) + errors.extend(self._default_errors(policy.get("default"))) + if errors: + return PolicyValidation(errors, []) + + fetch = self._fetch_schema(client) + if not fetch.schema: + return PolicyValidation([], [f"Live schema validation was skipped: {fetch.reason}"]) + errors = draft7_errors(policy, fetch.schema) + if _rls_condition.rules_use_wildcard(policy["rules"]) and ( + old := _rls_condition.schema_lacks_wildcard(fetch.schema) + ): + errors.append( + f"the wildcard {_rls_condition.WILDCARD!r} needs {self.item_type} schema 1.1.0 or later; " + f"this metastore serves {old}, where it is a literal that matches nobody" + ) + return PolicyValidation(errors, []) + + def _local_rule_errors(self, rules: Any) -> list[str]: + """Schema-independent checks on ``rules`` (hook for :class:`ClsService`).""" + return _rls_condition.validate_rules_local(rules) + + @staticmethod + def _default_errors(default: Any) -> list[str]: + """Checks on the policy-level ``default`` condition (hook: :class:`ClsService` has none).""" + if default is None: + return [] + return [f"default.{err}" for err in _rls_condition.validate_condition_ops(default)] + + def _preview_rules(self, rules: list[dict[str, Any]], dialect: str) -> list[dict[str, Any]]: + """Per-rule display preview shown by ``--dry-run`` (hook for :class:`ClsService`).""" + return [ + { + **_rls_condition.rule_selector(rule), + "condition": _rls_condition.compile_condition_preview(rule["condition"], dialect), + } + for rule in rules + ] + + def _preview(self, policy: dict[str, Any]) -> dict[str, Any]: + """The ``preview`` (and, for RLS, ``default_preview``) keys of a write/dry-run result.""" + preview: dict[str, Any] = { + "preview": self._preview_rules(policy["rules"], policy["dialect"]) + } + if policy.get("default") is not None: + preview["default_preview"] = _rls_condition.compile_condition_preview( + policy["default"], policy["dialect"] + ) + return preview + + @staticmethod + def _with_warnings(result: dict[str, Any], warnings: list[str]) -> dict[str, Any]: + """Attach validation warnings (e.g. the live schema was unavailable) only when there are any.""" + if warnings: + result["warnings"] = warnings + return result + + def _raise_invalid(self, errors: list[str]) -> NoReturn: + raise KeboolaApiError( + message=f"{self.label} policy is invalid: " + "; ".join(errors), + status_code=400, + error_code=self.invalid_error_code, + retryable=False, + ) + + @staticmethod + def _raise_usage(message: str) -> NoReturn: + raise KeboolaApiError( + message=message, status_code=400, error_code=ErrorCode.INVALID_ARGUMENT, retryable=False + ) + + @property + def _conflict_hint(self) -> str: + """The 409 remedy: policies are named by their table, one per table and project.""" + group = self.label.lower() + return ( + f"This project already has an {self.label} policy for that table: change it with " + f"`{group} update --policy-id ...`, or `{group} delete` it first." + ) + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def list_policies(self, alias: str) -> dict[str, Any]: + """List every policy object visible to ``alias``'s project.""" + project = self._resolve_one_project(alias) + with self._new_metastore_client(project) as client: + raw = client.list_items(self.item_type) + return {"project": alias, "policies": [self._row_from_item(item) for item in raw]} + + def get_policy(self, alias: str, policy_id: str) -> dict[str, Any]: + """Fetch one policy object's full attributes + meta.""" + project = self._resolve_one_project(alias) + with self._new_metastore_client(project) as client: + return self._detail_row(client.get_item(self.item_type, policy_id)) + + def _detail_row(self, item: dict[str, Any]) -> dict[str, Any]: + attrs = item.get("attributes") or {} + row = self._row_from_item(item) + row["rules"] = attrs.get("rules", []) + if attrs.get("default") is not None: + row["default"] = attrs["default"] + row["revision"] = (item.get("meta") or {}).get("revision") + return row + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def create_policy( + self, + alias: str, + *, + table: str, + rules: list[dict[str, Any]], + dialect: str | None = None, + scope: str = "targeted", + target_projects: Sequence[int | str] | None = None, + default: dict[str, Any] | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """Create one policy -- at ``targeted`` scope (default) or, explicitly, ``organization``. + + ``dialect`` defaults to the project backend and must equal it. ``default`` (RLS only) is the + condition for identities no rule matches; omitted, their reads are refused. ``target_projects`` (aliases or + project IDs) only with ``targeted``; they go in the create request itself, which stores the + grants in the same transaction (and needs the organization-admin role). ``dry_run=True`` + returns the preview without calling the write endpoint. + """ + if scope not in tuple(PolicyScope): + self._raise_usage(f"scope must be one of {tuple(PolicyScope)}, got {scope!r}") + if target_projects and scope != "targeted": + self._raise_usage("target projects require scope 'targeted'") + target_ids = self._resolve_targets(alias, target_projects) + project = self._resolve_one_project(alias) + backend = self._project_dialect(project) + policy: dict[str, Any] = {"table": table, "dialect": dialect or backend, "rules": rules} + if default is not None: + policy["default"] = default + + with self._new_metastore_client(project) as client: + check = self._validate_policy(client, policy=policy, backend=backend) + if check.errors: + self._raise_invalid(check.errors) + preview = self._preview(policy) + if dry_run: + return self._with_warnings( + { + "project": alias, + **policy, + "scope": scope, + "target_project_ids": target_ids, + **preview, + "dry_run": True, + }, + check.warnings, + ) + created = client.post_item( + self.item_type, + name=table, + data=policy, + scope=cast(ObjectScope, str(scope)), # checked against PolicyScope above + target_project_ids=target_ids or None, + conflict_hint=self._conflict_hint, + ) + return self._with_warnings({**self._detail_row(created), **preview}, check.warnings) + + def update_policy( + self, + alias: str, + policy_id: str, + *, + table: str | None = None, + dialect: str | None = None, + rules: list[dict[str, Any]] | None = None, + target_projects: Sequence[int | str] | None = None, + default: dict[str, Any] | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """Update one policy: only the given fields change. + + Reads the policy to validate the MERGED result (the metastore's PATCH validates only the keys + it receives) and for the ``--dry-run`` preview, then sends a ``PATCH`` with only the changed + keys -- unknown keys and concurrent changes to other keys survive. + + ``target_projects``: ``None`` keeps the grants, a list replaces them, ``[]`` revokes them all. + Grants change first (an organization-admin-only call): when that is refused, nothing is written. + The result is re-read after the write, so it shows the grants as they now are. ``default`` + replaces the RLS default condition; a PATCH cannot remove it (recreate the policy for that). + """ + changes = { + key: value + for key, value in ( + ("table", table), + ("dialect", dialect), + ("rules", rules), + ("default", default), + ) + if value is not None + } + if not changes and target_projects is None: + self._raise_usage( + "nothing to update: pass a table, dialect, rules, default or target projects" + ) + target_ids = ( + self._resolve_targets(alias, target_projects) if target_projects is not None else None + ) + project = self._resolve_one_project(alias) + backend = self._project_dialect(project) + + with self._new_metastore_client(project) as client: + current = client.get_item(self.item_type, policy_id) + attrs = current.get("attributes") or {} + meta = current.get("meta") or {} + scope = meta.get("scope", "") + if target_ids is not None and scope != "targeted": + self._raise_invalid( + [ + ( + f"the policy has {scope!r} scope, which has no target projects " + "(organization scope already applies everywhere and cannot be narrowed)" + ) + ] + ) + # A 1.1.0 policy may omit `dialect` (the enforcement then uses the workspace backend). + policy = { + "table": attrs.get("table", ""), + "dialect": attrs.get("dialect") or backend, + "rules": attrs.get("rules", []), + **({"default": attrs["default"]} if attrs.get("default") is not None else {}), + **changes, + } + check = self._validate_policy(client, policy=policy, backend=backend) + if check.errors: + self._raise_invalid(check.errors) + preview = self._preview(policy) + if dry_run: + return self._with_warnings( + { + "project": alias, + "policy_id": policy_id, + **policy, + "scope": scope, + "target_project_ids": ( + target_ids + if target_ids is not None + else meta.get("targetProjectIds") or [] + ), + **preview, + "dry_run": True, + }, + check.warnings, + ) + if target_ids is not None: + client.put_target_projects(self.item_type, policy_id, target_ids) + if changes: + client.patch_item( + self.item_type, + policy_id, + name=table, + data=changes, + conflict_hint=self._conflict_hint, + ) + row = self._detail_row(client.get_item(self.item_type, policy_id)) + return self._with_warnings({**row, **preview}, check.warnings) + + def delete_policy(self, alias: str, policy_id: str, *, dry_run: bool = False) -> dict[str, Any]: + """Delete one policy by id; ``dry_run`` shows what would be deleted (one read, no write).""" + project = self._resolve_one_project(alias) + with self._new_metastore_client(project) as client: + if dry_run: + return { + "project": alias, + "policy": self._detail_row(client.get_item(self.item_type, policy_id)), + "dry_run": True, + } + client.delete_item(self.item_type, policy_id) + return {"project": alias, "policy_id": policy_id, "deleted": True} diff --git a/src/keboola_agent_cli/services/semantic_layer_service.py b/src/keboola_agent_cli/services/semantic_layer_service.py index 13ccab8e..b04cf472 100644 --- a/src/keboola_agent_cli/services/semantic_layer_service.py +++ b/src/keboola_agent_cli/services/semantic_layer_service.py @@ -24,7 +24,7 @@ from ..auth.sentinel import require_static_token from ..config_store import ConfigStore from ..errors import ConfigError, ErrorCode, KeboolaApiError -from ..metastore_client import MetastoreClient, ObjectScope, SemanticType +from ..metastore_client import MetastoreClient, ObjectScope, SemanticType, fetch_resolved_schema from ..models import ProjectConfig from . import _semantic_layer_reference_data as _refdata from . import _semantic_layer_scope as _scope @@ -359,26 +359,12 @@ def get_schema(self, alias: str, types: list[str]) -> dict[str, Any]: def _fetch_resolved_schema(client: MetastoreClient, wire_type: SemanticType) -> dict[str, Any]: """Fetch the actual JSON Schema for a type, resolving the default version. - Live metastore behavior (2026-07): the bare ``/api/v1/schema/{type}`` - endpoint returns only a ``{"versions": [...]}`` listing (metadata, no - schema body); the real JSON Schema lives at ``/{version}``. The - upstream MCP tool passes the bare listing through -- an upstream gap - we deliberately do NOT mirror: this resolves ``isDefault`` (falling - back to the first entry) and fetches the versioned document. If the - server someday returns the schema directly (no ``versions`` key), - it is passed through unchanged. + See :func:`fetch_resolved_schema` -- the bare endpoint is only a version + listing. (The upstream MCP tool passes that bare listing through; we + deliberately do not mirror that gap.) """ - body = client.get_schema(wire_type) - versions = body.get("versions") - if not isinstance(versions, list) or not versions: - return {"schema": body, "schema_version": None} - default = next( - (v for v in versions if v.get("isDefault")), - versions[0], - ) - version_id = str(default.get("version", "")) - resolved = client.get_schema(wire_type, version=version_id) if version_id else body - return {"schema": resolved, "schema_version": version_id or None} + resolved = fetch_resolved_schema(client, wire_type) + return {"schema": resolved.schema, "schema_version": resolved.version} # Internal helpers (model-scoped fetches). diff --git a/tests/test_cls_cli.py b/tests/test_cls_cli.py new file mode 100644 index 00000000..1aaf272c --- /dev/null +++ b/tests/test_cls_cli.py @@ -0,0 +1,543 @@ +"""Tests for `kbagent cls` CLI commands via CliRunner. + +Covers JSON + human output, exit codes (ConfigError 5, API error mapping, +invalid arguments 2), confirmation prompts, and ``--dry-run`` / +``--yes`` / ``--json`` skipping the prompt. Mirrors ``test_rls_cli.py``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig + +runner = CliRunner() +RULES_JSON = json.dumps([{"principal": "a@x.com", "visible_columns": ["id", "region"]}]) +CREATE_ARGS = [ + "cls", + "create", + "--project", + "prod", + "--table-id", + "in.c-crm.invoices", + "--dialect", + "snowflake", + "--rules", + RULES_JSON, +] + + +def _store(tmp_path: Path) -> ConfigStore: + store = ConfigStore(config_dir=tmp_path / "cfg") + if "prod" not in store.load().projects: # a test may invoke `_run` more than once + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="999-token-abc", + project_name="prod", + project_id=1234, + ), + ) + return store + + +def _run(args: list[str], tmp_path: Path, service: MagicMock, input: str | None = None) -> Any: + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ClsService") as MockCls, + ): + MockStore.return_value = _store(tmp_path) + MockCls.return_value = service + return runner.invoke(app, args, input=input) + + +def _row(**overrides: Any) -> dict[str, Any]: + return { + "id": "p-1", + "table": "in.c-crm.invoices", + "dialect": "snowflake", + "rule_count": 1, + "scope": "organization", + "source_project_id": "5725", + "target_project_ids": [], + **overrides, + } + + +def _preview(**overrides: Any) -> dict[str, Any]: + return { + "table": "in.c-crm.invoices", + "dialect": "snowflake", + "scope": "organization", + "preview": [{"principal": "a@x.com", "visible_columns": ["id", "region"]}], + **overrides, + } + + +class TestList: + def test_json(self, tmp_path: Path) -> None: + service = MagicMock() + service.list_policies.return_value = {"project": "prod", "policies": [_row()]} + + result = _run(["--json", "cls", "list", "--project", "prod"], tmp_path, service) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["data"]["policies"][0]["table"] == "in.c-crm.invoices" + service.list_policies.assert_called_once_with(alias="prod") + + def test_human_table_and_empty(self, tmp_path: Path) -> None: + service = MagicMock() + service.list_policies.return_value = {"project": "prod", "policies": [_row()]} + assert ( + "in.c-crm.invoices" + in _run(["cls", "list", "--project", "prod"], tmp_path, service).output + ) + + service.list_policies.return_value = {"project": "prod", "policies": []} + assert ( + "No CLS policies found" + in _run(["cls", "list", "--project", "prod"], tmp_path, service).output + ) + + def test_config_error_exits_5(self, tmp_path: Path) -> None: + service = MagicMock() + service.list_policies.side_effect = ConfigError("unknown alias") + + assert _run(["cls", "list", "--project", "prod"], tmp_path, service).exit_code == 5 + + def test_api_error_is_mapped(self, tmp_path: Path) -> None: + service = MagicMock() + service.list_policies.side_effect = KeboolaApiError( + message="nope", status_code=403, error_code=ErrorCode.ACCESS_DENIED + ) + + result = _run(["--json", "cls", "list", "--project", "prod"], tmp_path, service) + + assert result.exit_code != 0 + assert "nope" in result.output + + +class TestDetail: + def test_json_and_human(self, tmp_path: Path) -> None: + service = MagicMock() + service.get_policy.return_value = _row( + rules=[{"principal": "a@x.com", "visible_columns": ["id", "region"]}], revision=3 + ) + + as_json = _run( + ["--json", "cls", "detail", "--project", "prod", "--policy-id", "p-1"], + tmp_path, + service, + ) + human = _run( + ["cls", "detail", "--project", "prod", "--policy-id", "p-1"], tmp_path, service + ) + + assert json.loads(as_json.output)["data"]["revision"] == 3 + assert "a@x.com: id, region" in human.output + service.get_policy.assert_called_with(alias="prod", policy_id="p-1") + + +class TestSchema: + def test_success(self, tmp_path: Path) -> None: + service = MagicMock() + service.fetch_schema.return_value = MagicMock(schema={"type": "object"}, reason=None) + + result = _run(["--json", "cls", "schema", "--project", "prod"], tmp_path, service) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["data"]["schema"] == {"type": "object"} + + def test_human_prints_json(self, tmp_path: Path) -> None: + service = MagicMock() + service.fetch_schema.return_value = MagicMock(schema={"title": "cls-policy"}, reason=None) + + assert ( + "cls-policy" in _run(["cls", "schema", "--project", "prod"], tmp_path, service).output + ) + + def test_fetch_failure_exits_4_with_cls_message(self, tmp_path: Path) -> None: + service = MagicMock() + service.fetch_schema.return_value = MagicMock(schema=None, reason="not registered") + + result = _run(["--json", "cls", "schema", "--project", "prod"], tmp_path, service) + + assert result.exit_code == 4 + assert "cls-policy schema" in result.output + + def test_config_error_exits_5(self, tmp_path: Path) -> None: + service = MagicMock() + service.fetch_schema.side_effect = ConfigError("unknown alias") + + assert _run(["cls", "schema", "--project", "prod"], tmp_path, service).exit_code == 5 + + +class TestCreate: + def test_json_skips_prompt_and_writes_once(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run(["--json", *CREATE_ARGS], tmp_path, service) + + assert result.exit_code == 0, result.output + service.create_policy.assert_called_once() + kwargs = service.create_policy.call_args.kwargs + assert kwargs["rules"][0]["visible_columns"] == ["id", "region"] + assert kwargs["dry_run"] is False + + def test_dry_run_prints_projection_without_prompt(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.return_value = _preview(dry_run=True) + + result = _run([*CREATE_ARGS, "--dry-run"], tmp_path, service) + + assert result.exit_code == 0, result.output + assert "a@x.com: SELECT id, region" in result.output + assert service.create_policy.call_args.kwargs["dry_run"] is True + + def test_confirmed_prompt_previews_then_writes(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run(CREATE_ARGS, tmp_path, service, input="y\n") + + assert result.exit_code == 0, result.output + assert "Created CLS policy p-1" in result.output + assert [c.kwargs["dry_run"] for c in service.create_policy.call_args_list] == [True, False] + + def test_declined_prompt_aborts_without_writing(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.return_value = _preview(dry_run=True) + + result = _run(CREATE_ARGS, tmp_path, service, input="n\n") + + assert result.exit_code == 0 + assert "Aborted" in result.output + assert [c.kwargs["dry_run"] for c in service.create_policy.call_args_list] == [True] + + def test_yes_skips_prompt_and_passes_target_projects(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run( + [*CREATE_ARGS, "--yes", "--target-project", "7", "--target-project", "8"], + tmp_path, + service, + ) + + assert result.exit_code == 0, result.output + service.create_policy.assert_called_once() + assert service.create_policy.call_args.kwargs["target_projects"] == ["7", "8"] + + def test_invalid_dialect_exits_2_before_any_call(self, tmp_path: Path) -> None: + service = MagicMock() + args = [a if a != "snowflake" else "oracle" for a in CREATE_ARGS] + + result = _run(["--json", *args], tmp_path, service) + + assert result.exit_code == 2 + service.create_policy.assert_not_called() + + @pytest.mark.parametrize("bad_rules", ["not json", '{"principal": "a@x.com"}']) + def test_invalid_rules_exit_2(self, tmp_path: Path, bad_rules: str) -> None: + service = MagicMock() + args = [*CREATE_ARGS[:-1], bad_rules] + + result = _run(["--json", *args], tmp_path, service) + + assert result.exit_code == 2 + if bad_rules.startswith("{"): + assert "{principal|principals, visible_columns}" in result.output + service.create_policy.assert_not_called() + + def test_validation_error_surfaces_cls_code(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.side_effect = KeboolaApiError( + message="CLS policy is invalid: x", + status_code=400, + error_code=ErrorCode.INVALID_CLS_POLICY, + ) + + result = _run(["--json", *CREATE_ARGS], tmp_path, service) + + assert result.exit_code != 0 + assert "INVALID_CLS_POLICY" in result.output + + +class TestUpdate: + def test_passes_only_given_flags(self, tmp_path: Path) -> None: + service = MagicMock() + service.update_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run( + [ + "--json", + "cls", + "update", + "--project", + "prod", + "--policy-id", + "p-1", + "--rules", + RULES_JSON, + ], + tmp_path, + service, + ) + + assert result.exit_code == 0, result.output + kwargs = service.update_policy.call_args.kwargs + assert kwargs["table"] is None + assert kwargs["dialect"] is None + assert kwargs["rules"][0]["principal"] == "a@x.com" + + def test_declined_prompt_aborts(self, tmp_path: Path) -> None: + service = MagicMock() + + result = _run( + ["cls", "update", "--project", "prod", "--policy-id", "p-1"], + tmp_path, + service, + input="n\n", + ) + + assert "Aborted" in result.output + service.update_policy.assert_not_called() + + def test_confirmed_human_output(self, tmp_path: Path) -> None: + service = MagicMock() + service.update_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run( + ["cls", "update", "--project", "prod", "--policy-id", "p-1"], + tmp_path, + service, + input="y\n", + ) + + assert "Updated CLS policy p-1" in result.output + + def test_dry_run_skips_prompt(self, tmp_path: Path) -> None: + service = MagicMock() + service.update_policy.return_value = _preview(dry_run=True) + + result = _run( + [ + "cls", + "update", + "--project", + "prod", + "--policy-id", + "p-1", + "--dialect", + "bigquery", + "--dry-run", + ], + tmp_path, + service, + ) + + assert result.exit_code == 0, result.output + assert service.update_policy.call_args.kwargs["dry_run"] is True + + def test_invalid_dialect_exits_2(self, tmp_path: Path) -> None: + service = MagicMock() + + result = _run( + [ + "--json", + "cls", + "update", + "--project", + "prod", + "--policy-id", + "p-1", + "--dialect", + "oracle", + ], + tmp_path, + service, + ) + + assert result.exit_code == 2 + service.update_policy.assert_not_called() + + +class TestDelete: + def test_json_skips_prompt(self, tmp_path: Path) -> None: + service = MagicMock() + service.delete_policy.return_value = { + "project": "prod", + "policy_id": "p-1", + "deleted": True, + } + + result = _run( + ["--json", "cls", "delete", "--project", "prod", "--policy-id", "p-1"], + tmp_path, + service, + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["data"]["deleted"] is True + + def test_declined_prompt_does_not_delete(self, tmp_path: Path) -> None: + service = MagicMock() + + result = _run( + ["cls", "delete", "--project", "prod", "--policy-id", "p-1"], + tmp_path, + service, + input="n\n", + ) + + assert "Aborted" in result.output + service.delete_policy.assert_not_called() + + def test_yes_deletes(self, tmp_path: Path) -> None: + service = MagicMock() + service.delete_policy.return_value = {"deleted": True} + + result = _run( + ["cls", "delete", "--project", "prod", "--policy-id", "p-1", "--yes"], tmp_path, service + ) + + assert "Deleted CLS policy p-1" in result.output + service.delete_policy.assert_called_once_with(alias="prod", policy_id="p-1", dry_run=False) + + +def test_permission_classes_of_the_cls_operations(tmp_path: Path) -> None: + """`cls.*` in OPERATION_REGISTRY: create/update=admin, delete=destructive, reads=read.""" + from keboola_agent_cli.permissions import OPERATION_REGISTRY + + assert {op: OPERATION_REGISTRY[f"cls.{op}"] for op in ("list", "detail", "schema")} == { + "list": "read", + "detail": "read", + "schema": "read", + } + assert all(OPERATION_REGISTRY[f"cls.{op}"] == "admin" for op in ("create", "update")) + assert OPERATION_REGISTRY["cls.delete"] == "destructive" + + +class TestClsReviewFixes: + def test_schema_auth_failure_is_an_error_not_a_missing_schema(self, tmp_path: Path) -> None: + service = MagicMock() + service.fetch_schema.side_effect = KeboolaApiError( + message="bad token", status_code=401, error_code=ErrorCode.INVALID_TOKEN + ) + + result = _run(["--json", "cls", "schema", "--project", "prod"], tmp_path, service) + + assert result.exit_code == 3 + assert "INVALID_TOKEN" in result.output + + def test_update_clear_target_projects_passes_an_empty_list(self, tmp_path: Path) -> None: + service = MagicMock() + service.update_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run( + [ + "--json", + "cls", + "update", + "--project", + "prod", + "--policy-id", + "p-1", + "--clear-target-projects", + ], + tmp_path, + service, + ) + + assert result.exit_code == 0, result.output + assert service.update_policy.call_args.kwargs["target_projects"] == [] + + def test_update_clear_and_target_project_are_mutually_exclusive(self, tmp_path: Path) -> None: + service = MagicMock() + + result = _run( + [ + "--json", "cls", "update", "--project", "prod", "--policy-id", "p-1", + "--clear-target-projects", "--target-project", "7", + ], + tmp_path, + service, + ) # fmt: skip + + assert result.exit_code == 2 + service.update_policy.assert_not_called() + + def test_create_prints_the_validation_warning_in_human_mode(self, tmp_path: Path) -> None: + service = MagicMock() + service.create_policy.return_value = { + **_row(), + "preview": _preview()["preview"], + "warnings": ["Live schema validation was skipped: no schema"], + } + + result = _run([*CREATE_ARGS, "--yes"], tmp_path, service) + + assert result.exit_code == 0, result.output + assert "Live schema validation was skipped" in result.output + + +class TestDestructiveGates: + """`--scope organization` and `delete` are destructive-class: `--deny-destructive` blocks them.""" + + @pytest.mark.parametrize(("extra", "exit_code"), [([], 0), (["--scope", "organization"], 6)]) + def test_deny_destructive_blocks_only_organization_scope( + self, tmp_path: Path, extra: list[str], exit_code: int + ) -> None: + service = MagicMock() + service.create_policy.return_value = {**_row(), "preview": _preview()["preview"]} + + result = _run(["--json", "--deny-destructive", *CREATE_ARGS, *extra], tmp_path, service) + + assert result.exit_code == exit_code, result.output + assert service.create_policy.called is (exit_code == 0) + + def test_deny_destructive_blocks_delete(self, tmp_path: Path) -> None: + service = MagicMock() + + result = _run( + [ + "--json", + "--deny-destructive", + "cls", + "delete", + "--project", + "prod", + "--policy-id", + "p-1", + ], + tmp_path, + service, + ) + + assert result.exit_code == 6 + service.delete_policy.assert_not_called() + + def test_delete_dry_run_needs_no_confirm(self, tmp_path: Path) -> None: + service = MagicMock() + service.delete_policy.return_value = {"project": "prod", "policy": _row(), "dry_run": True} + + result = _run( + ["cls", "delete", "--project", "prod", "--policy-id", "p-1", "--dry-run"], + tmp_path, + service, + ) + + assert result.exit_code == 0, result.output + assert "Would delete" in result.output + service.delete_policy.assert_called_once_with(alias="prod", policy_id="p-1", dry_run=True) diff --git a/tests/test_cls_service.py b/tests/test_cls_service.py new file mode 100644 index 00000000..439c0202 --- /dev/null +++ b/tests/test_cls_service.py @@ -0,0 +1,320 @@ +"""Service-layer tests for ``ClsService`` (column-level security). + +Same approach as ``test_rls_service.py``: a ``MagicMock`` metastore client +factory, so we verify orchestration (item type, scope resolution, validation +order, fetch-then-merge) without HTTP. The shared plumbing is inherited from +``RlsService`` and covered there; these tests pin what ``ClsService`` changes. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import ANY, MagicMock + +import pytest + +from keboola_agent_cli.errors import ErrorCode, KeboolaApiError +from keboola_agent_cli.services.cls_service import ClsService + +from .test_rls_service import _make_store, storage_client_factory + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +_RULES = [{"principal": "a@x.com", "visible_columns": ["id", "region"]}] + + +def _make_service(tmp_path: Path) -> tuple[ClsService, MagicMock]: + """The RLS fixtures (same store and Storage mock), with a CLS service and no live schema.""" + store = _make_store(tmp_path) + mock = MagicMock() + mock.__enter__ = MagicMock(return_value=mock) + mock.__exit__ = MagicMock(return_value=False) + mock.get_schema.side_effect = KeboolaApiError( + message="no schema", status_code=404, error_code=ErrorCode.NOT_FOUND + ) + mock.list_items.return_value = [] + service = ClsService( + config_store=store, + client_factory=storage_client_factory(), + metastore_client_factory=lambda url, token: mock, + ) + return service, mock + + +def _item(rules: list[dict[str, Any]] | None = None, **meta: Any) -> dict[str, Any]: + return { + "type": "cls-policy", + "id": "p-1", + "attributes": { + "table": "in.c-crm.invoices", + "dialect": "snowflake", + "rules": _RULES if rules is None else rules, + }, + "meta": {"scope": "organization", "sourceProjectId": "5725", "revision": 2, **meta}, + } + + +class TestLocalRuleValidation: + @pytest.mark.parametrize( + ("rules", "fragment"), + [ + (None, "non-empty array"), + ([], "non-empty array"), + (["x"], "must be an object"), + ([{"visible_columns": ["id"]}], "exactly one of principal/principals/groups"), + ( + [{"principal": "a@x.com", "principals": ["b@x.com"], "visible_columns": ["id"]}], + "exactly one of principal/principals/groups", + ), + ([{"principal": "a@x.com"}], "non-empty 'visible_columns'"), + ([{"principal": "a@x.com", "visible_columns": []}], "non-empty 'visible_columns'"), + ([{"principal": "a@x.com", "visible_columns": "id"}], "non-empty 'visible_columns'"), + ( + [{"principal": "a@x.com", "visible_columns": ["id; DROP"]}], + "not a valid column name", + ), + ([{"principal": "a@x.com", "visible_columns": [1]}], "not a valid column name"), + ( + [{"principals": ["*", "a@x.com"], "visible_columns": ["id"]}], + "must be the only entry", + ), + ([{"groups": ["*", "sales"], "visible_columns": ["id"]}], "must be the only entry"), + ], + ) + def test_rejects(self, tmp_path: Path, rules: Any, fragment: str) -> None: + service, mock = _make_service(tmp_path) + + with pytest.raises(KeboolaApiError) as exc: + service.create_policy("prod", table="in.c-crm.t", dialect="snowflake", rules=rules) + + assert exc.value.error_code == ErrorCode.INVALID_CLS_POLICY + assert fragment in exc.value.message + assert exc.value.message.startswith("CLS policy is invalid") + mock.post_item.assert_not_called() + + @pytest.mark.parametrize( + "selector", [{"principal": "*"}, {"principals": ["*"]}, {"groups": ["*"]}] + ) + def test_accepts_the_wildcard_selector_alone(self, tmp_path: Path, selector: dict) -> None: + service, _ = _make_service(tmp_path) + + result = service.create_policy( + "prod", + table="in.c-crm.t", + dialect="snowflake", + rules=[{**selector, "visible_columns": ["id"]}], + dry_run=True, + ) + + assert result["preview"] == [{**selector, "visible_columns": ["id"]}] + + def test_accepts_principals_list(self, tmp_path: Path) -> None: + service, _ = _make_service(tmp_path) + rules = [{"principals": ["a@x.com", "b@x.com"], "visible_columns": ["id"]}] + + result = service.create_policy( + "prod", table="in.c-crm.t", dialect="snowflake", rules=rules, dry_run=True + ) + + assert result["preview"] == [ + {"principals": ["a@x.com", "b@x.com"], "visible_columns": ["id"]} + ] + + def test_accepts_groups_and_rejects_default(self, tmp_path: Path) -> None: + """1.1.0: a groups selector; a cls-policy has no `default`.""" + service, _ = _make_service(tmp_path) + rules = [{"groups": ["sales-eu"], "visible_columns": ["id"]}] + + result = service.create_policy("prod", table="in.c-crm.t", rules=rules, dry_run=True) + + assert result["preview"] == [{"groups": ["sales-eu"], "visible_columns": ["id"]}] + with pytest.raises(KeboolaApiError, match="'default' is not supported"): + service.create_policy("prod", table="in.c-crm.t", rules=rules, default={"false": True}) + + def test_a_dialect_other_than_the_backend_is_rejected_before_any_write( + self, tmp_path: Path + ) -> None: + service, mock = _make_service(tmp_path) + + with pytest.raises(KeboolaApiError, match="does not match the project backend"): + service.create_policy("prod", table="in.c-crm.t", dialect="bigquery", rules=_RULES) + + mock.post_item.assert_not_called() + + +class TestStructuralValidation: + def test_live_schema_violation_is_reported_as_cls_error(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_schema.side_effect = None + mock.get_schema.return_value = { + "type": "object", + "properties": {"table": {"type": "string", "pattern": "^[a-z.]+$"}}, + } + + with pytest.raises(KeboolaApiError) as exc: + service.create_policy("prod", table="BAD TABLE", dialect="snowflake", rules=_RULES) + + mock.get_schema.assert_called_with("cls-policy") + assert exc.value.error_code == ErrorCode.INVALID_CLS_POLICY + assert "Schema error at table" in exc.value.message + + def test_version_listing_is_resolved_to_the_real_schema_before_validating( + self, tmp_path: Path + ) -> None: + """The live metastore's bare endpoint returns only `{"versions": [...]}`; validating + against that listing would pass anything, so the default version must be fetched.""" + service, mock = _make_service(tmp_path) + real_schema = { + "type": "object", + "properties": {"table": {"type": "string", "pattern": "^[a-z.]+$"}}, + } + mock.get_schema.side_effect = [ + {"versions": [{"version": "0.9.0"}, {"version": "1.0.0", "isDefault": True}]}, + real_schema, + ] + + with pytest.raises(KeboolaApiError) as exc: + service.create_policy("prod", table="BAD TABLE", dialect="snowflake", rules=_RULES) + + assert [c.args + tuple(c.kwargs.items()) for c in mock.get_schema.call_args_list] == [ + ("cls-policy",), + ("cls-policy", ("version", "1.0.0")), + ] + assert "Schema error at table" in exc.value.message + + def test_missing_schema_degrades_instead_of_blocking(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.post_item.return_value = _item() + + service.create_policy("prod", table="in.c-crm.invoices", dialect="snowflake", rules=_RULES) + + mock.post_item.assert_called_once() + + def test_fetch_schema_uses_cls_item_type(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_schema.side_effect = None + mock.get_schema.return_value = {"type": "object"} + + fetch = service.fetch_schema("prod") + + mock.get_schema.assert_called_once_with("cls-policy") + assert fetch.schema == {"type": "object"} + + +class TestReads: + def test_list_uses_cls_item_type(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.list_items.return_value = [_item()] + + result = service.list_policies("prod") + + mock.list_items.assert_called_once_with("cls-policy") + assert result["policies"][0]["rule_count"] == 1 + + def test_detail_returns_visible_columns(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_item.return_value = _item() + + result = service.get_policy("prod", "p-1") + + mock.get_item.assert_called_once_with("cls-policy", "p-1") + assert result["rules"] == _RULES + assert result["revision"] == 2 + + +class TestCreate: + def test_writes_cls_policy_at_targeted_scope_by_default(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.post_item.return_value = _item(scope="targeted") + + result = service.create_policy("prod", table="in.c-crm.invoices", rules=_RULES) + + mock.post_item.assert_called_once_with( + "cls-policy", + name="in.c-crm.invoices", + data={"table": "in.c-crm.invoices", "dialect": "snowflake", "rules": _RULES}, + scope="targeted", + target_project_ids=None, + conflict_hint=ANY, + ) + assert result["preview"] == [{"principal": "a@x.com", "visible_columns": ["id", "region"]}] + + def test_target_projects_are_granted_in_the_create_request(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.post_item.return_value = _item(scope="targeted") + + service.create_policy( + "prod", table="in.c-crm.invoices", rules=_RULES, target_projects=["7,8"] + ) + + assert mock.post_item.call_args.kwargs["target_project_ids"] == [7, 8] + mock.put_target_projects.assert_not_called() + + def test_one_identity_in_several_rules_is_accepted(self, tmp_path: Path) -> None: + """1.1.0: an identity several rules match sees the union of their columns.""" + service, mock = _make_service(tmp_path) + mock.post_item.return_value = _item() + rules = [*_RULES, {"principal": "A@x.com", "visible_columns": ["id"]}] + + service.create_policy("prod", table="in.c-crm.t", rules=rules) + + mock.post_item.assert_called_once() + + def test_dry_run_never_writes(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + + result = service.create_policy("prod", table="in.c-crm.t", rules=_RULES, dry_run=True) + + assert result["dry_run"] is True + assert result["scope"] == "targeted" + mock.post_item.assert_not_called() + + +class TestUpdate: + def test_patches_only_the_given_fields(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_item.return_value = _item() + new_rules = [{"principal": "b@x.com", "visible_columns": ["id"]}] + + service.update_policy("prod", "p-1", rules=new_rules) + + mock.patch_item.assert_called_once_with( + "cls-policy", "p-1", name=None, data={"rules": new_rules}, conflict_hint=ANY + ) + + def test_keeps_existing_targets_and_scope(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_item.return_value = _item(scope="targeted", targetProjectIds=["7"]) + + service.update_policy("prod", "p-1", table="in.c-crm.other") + + mock.put_target_projects.assert_not_called() # the omitted option re-sends no grants + + def test_dry_run_never_writes(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_item.return_value = _item() + + result = service.update_policy("prod", "p-1", table="in.c-crm.other", dry_run=True) + + assert result["dry_run"] is True + assert result["table"] == "in.c-crm.other" + mock.patch_item.assert_not_called() + + def test_invalid_merged_rules_are_rejected(self, tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + mock.get_item.return_value = _item() + + with pytest.raises(KeboolaApiError) as exc: + service.update_policy("prod", "p-1", rules=[{"principal": "a@x.com"}]) + + assert exc.value.error_code == ErrorCode.INVALID_CLS_POLICY + mock.patch_item.assert_not_called() + + +def test_delete_uses_cls_item_type(tmp_path: Path) -> None: + service, mock = _make_service(tmp_path) + + result = service.delete_policy("prod", "p-1") + + mock.delete_item.assert_called_once_with("cls-policy", "p-1") + assert result == {"project": "prod", "policy_id": "p-1", "deleted": True} diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 71bb91dd..1f29c21e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -52,6 +52,7 @@ import shutil import subprocess import time +import uuid from collections.abc import Generator from datetime import UTC, datetime from pathlib import Path @@ -677,6 +678,18 @@ def test_full_cli_e2e(self) -> None: _step(40, "storage delete-column", "dry-run + actual delete + verify") self._test_delete_column(table_id) + # ============================================================== + # PHASE 14.5: Row-level security (CLI-17) + # ============================================================== + + _step( + "40.5", + "rls/cls schema + list", + "the rls-policy / cls-policy metastore object types may not be registered on a stack", + ) + self._test_rls_commands() + self._test_rls_cls_crud() + # ============================================================== # PHASE 15: Cleanup # ============================================================== @@ -3710,6 +3723,102 @@ def _test_mcp_parity_commands(self) -> None: except Exception as exc: print(f" WARN: failed to delete transformation {tf_config_id}: {exc}") + def _test_rls_commands(self) -> None: + """`rls`/`cls` `schema` + `list` -- the `rls-policy` / `cls-policy` metastore + object types are only registered on stacks with a recent metastore, so + a clean, classified failure here is the expected outcome, not a + kbagent bug -- same SKIP-on-failure shape as `_test_mcp_parity_commands`'s + `semantic-layer schema` case above. Full CRUD coverage (create/update/ + delete against a real `rls-policy` object) is exercised against a + mocked `MetastoreClient` in `tests/test_rls_service.py` / + `tests/test_rls_cli.py` and will get a live E2E round-trip once the + backend registers the type. + """ + for group in ("rls", "cls"): + result = self._run(group, "schema", "--project", self.alias) + if result.exit_code != 0: + # Only a missing object type is an expected skip. An auth failure or any other error + # must fail the suite instead of being skipped along with it. + assert "NOT_FOUND" in result.output, result.output + print( + f" {_YELLOW}SKIP: {group} schema ({group}-policy not registered here){_RESET}" + ) + else: + data = _json_ok(result) + assert isinstance(data["data"]["schema"], dict) and data["data"]["schema"] + assert data["data"]["source"] == "live" + + # `list` must degrade the same way -- never a traceback -- on a stack + # whose metastore lacks the object type. + result = self._run(group, "list", "--project", self.alias) + if result.exit_code != 0: + assert "NOT_FOUND" in result.output, result.output + print(f" {_YELLOW}SKIP: {group} list ({group}-policy not registered here){_RESET}") + else: + data = _json_ok(result) + assert isinstance(data["data"]["policies"], list) + + def _test_rls_cls_crud(self) -> None: + """create -> detail -> update -> delete for both policy types, then the `rls setup` refusal. + + The policy is `targeted` (the default) with no grants and the project's own dialect (the + default), so it only ever governs this project -- never the shared organization, where an + organization-scope policy would reach every project with the feature. Authoring needs a + master token on a stack whose metastore registers the type, so a classified NOT_FOUND / + ACCESS_DENIED / MISSING_MASTER_TOKEN on the create is an expected skip (the policy is never + created then); any other failure fails the suite. The policy is always deleted again. + Targets a table that does not exist, so no real data is ever governed. + """ + # Policies are named by table: a unique table keeps concurrent runs from colliding (409). + table = f"in.c-kbagent-e2e-rls.policy_crud_{uuid.uuid4().hex[:8]}" + samples = { + "rls": ( + '[{"principal":"e2e@example.com","condition":{"column":"id","op":"eq","value":"x"}}]', + '[{"principal":"e2e-2@example.com","condition":{"column":"id","op":"eq","value":"y"}}]', + ), + "cls": ( + '[{"principal":"e2e@example.com","visible_columns":["id"]}]', + '[{"principal":"e2e-2@example.com","visible_columns":["id"]}]', + ), + } + for group, (rules, new_rules) in samples.items(): + created = self._run( + group, "create", "--project", self.alias, "--table-id", table, "--rules", rules, + ) # fmt: skip + if created.exit_code != 0: + assert any( + code in created.output + for code in ("NOT_FOUND", "ACCESS_DENIED", "MISSING_MASTER_TOKEN") + ), created.output + print(f" {_YELLOW}SKIP: {group} create/update/delete (cannot author here){_RESET}") + continue + policy_id = _json_ok(created)["data"]["id"] + try: + assert _json_ok(created)["data"]["scope"] == "targeted" + detail = self._run_ok( + group, "detail", "--project", self.alias, "--policy-id", policy_id + ) + assert detail["data"]["table"] == table + updated = self._run_ok( + group, "update", "--project", self.alias, "--policy-id", policy_id, + "--rules", new_rules, + ) # fmt: skip + assert updated["data"]["rules"][0]["principal"] == "e2e-2@example.com" + assert updated["data"]["table"] == table # the partial update kept the table + previewed = self._run_ok( + group, "delete", "--project", self.alias, "--policy-id", policy_id, "--dry-run" + ) + assert previewed["data"]["dry_run"] is True + finally: + deleted = self._run( + group, "delete", "--project", self.alias, "--policy-id", policy_id + ) + assert deleted.exit_code == 0, deleted.output + # Under --json (no terminal) the guided wizard refuses before any API call. + refused = self._run("rls", "setup", "--project", self.alias) + assert refused.exit_code == 2, refused.output + assert "INVALID_ARGUMENT" in refused.output + def _test_job_commands(self) -> None: """Verify job listing structure and detail (if jobs exist).""" # job list diff --git a/tests/test_metastore_client.py b/tests/test_metastore_client.py index 17eee403..abbf0598 100644 --- a/tests/test_metastore_client.py +++ b/tests/test_metastore_client.py @@ -9,6 +9,7 @@ import json from typing import ClassVar +from unittest.mock import MagicMock import pytest @@ -17,6 +18,8 @@ from keboola_agent_cli.metastore_client import ( SEMANTIC_TYPES, MetastoreClient, + ResolvedSchema, + fetch_resolved_schema, ) STACK_URL_US = "https://connection.keboola.com" @@ -285,6 +288,48 @@ def test_delete_404(self, httpx_mock) -> None: assert excinfo.value.error_code == ErrorCode.NOT_FOUND +class TestPatchItem: + """patch_item sends only the given fields -- the server merges them into the stored record.""" + + _URL = f"{METASTORE_URL_US}/api/v1/repository/rls-policy/p-1" + + @pytest.mark.parametrize( + ("kwargs", "body"), + [ + ({"data": {"rules": []}}, {"data": {"rules": []}}), + ({"name": "t.x", "data": {"table": "t.x"}}, {"name": "t.x", "data": {"table": "t.x"}}), + ], + ) + def test_sends_only_the_given_fields(self, httpx_mock, kwargs: dict, body: dict) -> None: + httpx_mock.add_response( + method="PATCH", url=self._URL, json={"data": {"id": "p-1"}}, status_code=200 + ) + client = MetastoreClient(stack_url=STACK_URL_US, token=TOKEN) + try: + stored = client.patch_item("rls-policy", "p-1", **kwargs) + finally: + client.close() + assert stored == {"id": "p-1"} + request = httpx_mock.get_requests()[0] + assert request.method == "PATCH" + assert json.loads(request.content) == body # never scope, branch or unchanged keys + + @pytest.mark.parametrize( + ("status", "code"), [(409, ErrorCode.ALREADY_EXISTS), (404, ErrorCode.NOT_FOUND)] + ) + def test_errors_are_classified(self, httpx_mock, status: int, code: ErrorCode) -> None: + httpx_mock.add_response( + method="PATCH", url=self._URL, json={"error": "x"}, status_code=status + ) + client = MetastoreClient(stack_url=STACK_URL_US, token=TOKEN) + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.patch_item("rls-policy", "p-1", name="t.x", data={"table": "t.x"}) + finally: + client.close() + assert excinfo.value.error_code == code + + class TestPutItem: """put_item wraps the same envelope as post_item but targets PUT /{type}/{id}.""" @@ -355,6 +400,8 @@ def test_semantic_types_complete(self) -> None: "semantic-constraint", "semantic-glossary", "semantic-reference-data", + "rls-policy", + "cls-policy", } @@ -742,3 +789,36 @@ def test_no_filters_sends_bare_request(self, httpx_mock, metastore_client) -> No status_code=200, ) assert metastore_client.list_organization_items("semantic-model") == [] + + +class TestFetchResolvedSchema: + """`fetch_resolved_schema`: the bare schema endpoint is only a version listing.""" + + @staticmethod + def _client(*responses: dict) -> MagicMock: + client = MagicMock() + client.get_schema.side_effect = list(responses) + return client + + def test_fetches_the_default_version(self) -> None: + listing = {"versions": [{"version": "0.9.0"}, {"version": "1.0.0", "isDefault": True}]} + client = self._client(listing, {"title": "real"}) + + resolved = fetch_resolved_schema(client, "rls-policy") + + assert (resolved.schema, resolved.version) == ({"title": "real"}, "1.0.0") + assert client.get_schema.call_args_list[1].kwargs == {"version": "1.0.0"} + + def test_falls_back_to_the_first_version_without_a_default(self) -> None: + client = self._client({"versions": [{"version": "2.0.0"}, {"version": "1.0.0"}]}, {"t": 1}) + + assert fetch_resolved_schema(client, "cls-policy") == ResolvedSchema({"t": 1}, "2.0.0") + + @pytest.mark.parametrize( + "body", [{"title": "direct"}, {"versions": []}, {"versions": [{"isDefault": True}]}] + ) + def test_passes_through_when_nothing_to_resolve(self, body: dict) -> None: + client = self._client(body) + + assert fetch_resolved_schema(client, "rls-policy") == ResolvedSchema(body, None) + client.get_schema.assert_called_once_with("rls-policy") diff --git a/tests/test_project_ref.py b/tests/test_project_ref.py index 299dc877..2cd64527 100644 --- a/tests/test_project_ref.py +++ b/tests/test_project_ref.py @@ -150,9 +150,11 @@ def test_no_shadow_notice_for_aliases_of_the_same_project(self) -> None: "--target-project-ids": "already project IDs (sharing share)", "--target-project": ( "semantic-layer: alias OR numeric ID resolved by the service itself (a target need not " - "be registered); config clone's --target-project is in ALIAS_OPTIONS" + "be registered); config clone's --target-project is in ALIAS_OPTIONS; " + "rls/cls create|update: a numeric project ID the policy is granted to, not an alias" ), "--all-projects": "a boolean flag", + "--clear-target-projects": "a boolean flag (rls/cls update): revoke every grant, takes no project", "--register-projects": "a boolean flag", "--remove-projects": "a boolean flag", } diff --git a/tests/test_rls_cli.py b/tests/test_rls_cli.py new file mode 100644 index 00000000..5fcf3757 --- /dev/null +++ b/tests/test_rls_cli.py @@ -0,0 +1,987 @@ +"""Tests for `kbagent rls` CLI commands via CliRunner -- CLI-17. + +Covers JSON output, human-mode rendering, exit codes for ConfigError / +KeboolaApiError / invalid arguments, --dry-run never triggering a write, and +`rls setup`'s non-TTY refusal (CliRunner's captured stdout is never a real +terminal, so it naturally exercises that path). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.commands._checkbox_select import _stdio_is_tty +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.output import OutputFormatter + +runner = CliRunner() +TEST_TOKEN = "999-token-abc" + + +def _setup_config(config_dir: Path, projects: dict[str, dict] | None = None) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + for alias, info in (projects or {}).items(): + store.add_project( + alias, + ProjectConfig( + stack_url=info.get("stack_url", "https://connection.keboola.com"), + token=info.get("token", TEST_TOKEN), + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + ), + ) + return store + + +def _run( + args: list[str], + store: ConfigStore, + mock_service: MagicMock, + input: str | None = None, + storage_service: MagicMock | None = None, +) -> Any: + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.RlsService") as MockRS, + patch("keboola_agent_cli.cli.StorageService") as MockSS, + ): + MockStore.return_value = store + MockRS.return_value = mock_service + MockSS.return_value = storage_service or MagicMock() + return runner.invoke(app, args, input=input) + + +def _policy_row(**overrides: Any) -> dict[str, Any]: + row = { + "id": "p-1", + "table": "in.c-crm.invoices", + "dialect": "snowflake", + "rule_count": 1, + "scope": "organization", + "source_project_id": "5725", + "target_project_ids": [], + } + row.update(overrides) + return row + + +# --------------------------------------------------------------------------- +# rls list +# --------------------------------------------------------------------------- + + +class TestRlsListCli: + def test_json_output(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.list_policies.return_value = {"project": "prod", "policies": [_policy_row()]} + + result = _run(["--json", "rls", "list", "--project", "prod"], store, service) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output)["data"] + assert payload["policies"][0]["table"] == "in.c-crm.invoices" + + def test_human_output_shows_table(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.list_policies.return_value = {"project": "prod", "policies": [_policy_row()]} + + result = _run(["rls", "list", "--project", "prod"], store, service) + + assert result.exit_code == 0 + assert "in.c-crm.invoices" in result.output + + def test_empty_list(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.list_policies.return_value = {"project": "prod", "policies": []} + + result = _run(["rls", "list", "--project", "prod"], store, service) + + assert result.exit_code == 0 + assert "No RLS policies found" in result.output + + def test_config_error_exit_5(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {}) + service = MagicMock() + service.list_policies.side_effect = ConfigError("unknown alias 'prod'") + + result = _run(["rls", "list", "--project", "prod"], store, service) + + assert result.exit_code == 5 + + def test_api_error_mapped(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.list_policies.side_effect = KeboolaApiError( + message="boom", status_code=401, error_code=ErrorCode.INVALID_TOKEN + ) + + result = _run(["rls", "list", "--project", "prod"], store, service) + + assert result.exit_code == 3 + + +# --------------------------------------------------------------------------- +# rls detail +# --------------------------------------------------------------------------- + + +class TestRlsDetailCli: + def test_json_output(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.get_policy.return_value = { + **_policy_row(), + "rules": [{"principal": "a@x.com", "condition": {"true": True}}], + } + + result = _run( + ["--json", "rls", "detail", "--project", "prod", "--policy-id", "p-1"], store, service + ) + + assert result.exit_code == 0 + payload = json.loads(result.output)["data"] + assert payload["rules"][0]["principal"] == "a@x.com" + service.get_policy.assert_called_once_with(alias="prod", policy_id="p-1") + + +# --------------------------------------------------------------------------- +# rls schema +# --------------------------------------------------------------------------- + + +class TestRlsSchemaCli: + def test_success(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + fetch = MagicMock(schema={"type": "object"}, reason=None) + service.fetch_schema.return_value = fetch + + result = _run(["--json", "rls", "schema", "--project", "prod"], store, service) + + assert result.exit_code == 0 + payload = json.loads(result.output)["data"] + assert payload["schema"] == {"type": "object"} + assert payload["source"] == "live" + + def test_fetch_failure_is_exit_4_not_a_crash(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + fetch = MagicMock(schema=None, reason="404 not found -- backend not registered yet") + service.fetch_schema.return_value = fetch + + result = _run(["rls", "schema", "--project", "prod"], store, service) + + assert result.exit_code == 4 + assert "not registered yet" in result.output or "Could not fetch" in result.output + + +# --------------------------------------------------------------------------- +# rls create +# --------------------------------------------------------------------------- + + +class TestRlsDefaultOption: + """`--default` (schema 1.1.0): a JSON condition passed through; anything else is a usage error.""" + + @pytest.mark.parametrize( + "command", + [["create", "--table-id", "t.x", "--rules", "[]"], ["update", "--policy-id", "p-1"]], + ) + def test_default_is_parsed_and_passed_to_the_service( + self, tmp_path: Path, command: list[str] + ) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "preview": []} + service.update_policy.return_value = {**_policy_row(), "preview": []} + + result = _run( + ["--json", "rls", *command, "--project", "prod", "--default", '{"false": true}'], + store, + service, + ) + + assert result.exit_code == 0, result.output + method = service.create_policy if command[0] == "create" else service.update_policy + assert method.call_args.kwargs["default"] == {"false": True} + + @pytest.mark.parametrize("raw", ["[1]", "not json"]) + def test_a_non_object_default_is_a_usage_error(self, tmp_path: Path, raw: str) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + + result = _run( + [ + "--json", + "rls", + "create", + "--project", + "prod", + "--table-id", + "t.x", + "--rules", + "[]", + "--default", + raw, + ], + store, + service, + ) + + assert result.exit_code == 2, result.output + service.create_policy.assert_not_called() + + +class TestRlsCreateCli: + _RULES_JSON = '[{"principal": "a@x.com", "condition": {"true": true}}]' + + def test_json_output_no_confirm_needed(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "preview": []} + + result = _run( + [ + "--json", + "rls", + "create", + "--project", + "prod", + "--table-id", + "in.c-crm.invoices", + "--dialect", + "snowflake", + "--rules", + self._RULES_JSON, + ], + store, + service, + ) + + assert result.exit_code == 0, result.output + service.create_policy.assert_called_once() + + def test_invalid_dialect_exit_2_before_service_call(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + + result = _run( + [ + "--json", + "rls", + "create", + "--project", + "prod", + "--table-id", + "t", + "--dialect", + "postgres", + "--rules", + self._RULES_JSON, + ], + store, + service, + ) + + assert result.exit_code == 2 + service.create_policy.assert_not_called() + + def test_invalid_rules_json_exit_2(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + + result = _run( + [ + "--json", + "rls", + "create", + "--project", + "prod", + "--table-id", + "t", + "--dialect", + "snowflake", + "--rules", + "not-json", + ], + store, + service, + ) + + assert result.exit_code == 2 + service.create_policy.assert_not_called() + + def test_dry_run_uses_dry_run_flag_and_needs_no_confirm(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "dry_run": True, "preview": []} + + result = _run( + [ + "rls", + "create", + "--project", + "prod", + "--table-id", + "t", + "--dialect", + "snowflake", + "--rules", + self._RULES_JSON, + "--dry-run", + ], + store, + service, + ) + + assert result.exit_code == 0, result.output + _, kwargs = service.create_policy.call_args + assert kwargs["dry_run"] is True + + def test_human_mode_without_yes_aborts_on_no(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "preview": []} + + result = _run( + [ + "rls", + "create", + "--project", + "prod", + "--table-id", + "t", + "--dialect", + "snowflake", + "--rules", + self._RULES_JSON, + ], + store, + service, + input="n\n", + ) + + assert result.exit_code == 0 + assert "Aborted" in result.output + # Only the pre-confirm preview call (dry_run=True) may have happened; + # the real write must never fire after a "no". + for call in service.create_policy.call_args_list: + assert call.kwargs.get("dry_run") is True + + def test_config_error_exit_5(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {}) + service = MagicMock() + service.create_policy.side_effect = ConfigError("unknown alias") + + result = _run( + [ + "--json", + "rls", + "create", + "--project", + "prod", + "--table-id", + "t", + "--dialect", + "snowflake", + "--rules", + self._RULES_JSON, + ], + store, + service, + ) + + assert result.exit_code == 5 + + +# --------------------------------------------------------------------------- +# rls update +# --------------------------------------------------------------------------- + + +class TestRlsUpdateCli: + def test_json_output(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.update_policy.return_value = {**_policy_row(), "preview": []} + + result = _run( + [ + "--json", + "rls", + "update", + "--project", + "prod", + "--policy-id", + "p-1", + "--table-id", + "in.c-crm.invoices", + ], + store, + service, + ) + + assert result.exit_code == 0, result.output + _, kwargs = service.update_policy.call_args + assert kwargs["policy_id"] == "p-1" + assert kwargs["rules"] is None # untouched flags stay None, not overwritten + + +# --------------------------------------------------------------------------- +# rls delete +# --------------------------------------------------------------------------- + + +class TestRlsDeleteCli: + def test_yes_flag_skips_confirm(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.delete_policy.return_value = { + "project": "prod", + "policy_id": "p-1", + "deleted": True, + } + + result = _run( + ["rls", "delete", "--project", "prod", "--policy-id", "p-1", "--yes"], store, service + ) + + assert result.exit_code == 0, result.output + service.delete_policy.assert_called_once_with(alias="prod", policy_id="p-1", dry_run=False) + + def test_human_mode_without_yes_aborts_on_no(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + + result = _run( + ["rls", "delete", "--project", "prod", "--policy-id", "p-1"], + store, + service, + input="n\n", + ) + + assert result.exit_code == 0 + assert "Aborted" in result.output + service.delete_policy.assert_not_called() + + def test_json_mode_needs_no_confirm(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.delete_policy.return_value = { + "project": "prod", + "policy_id": "p-1", + "deleted": True, + } + + result = _run( + ["--json", "rls", "delete", "--project", "prod", "--policy-id", "p-1"], store, service + ) + + assert result.exit_code == 0 + service.delete_policy.assert_called_once() + + +# --------------------------------------------------------------------------- +# rls setup (non-TTY refusal -- CliRunner's captured stdout is never a real +# terminal, so this exercises the exact same path a piped/non-interactive +# invocation would) +# --------------------------------------------------------------------------- + + +class TestRlsSetupCli: + def test_non_tty_refuses_and_hints_at_create(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + storage_service = MagicMock() + + result = _run( + ["rls", "setup", "--project", "prod"], + store, + service, + storage_service=storage_service, + ) + + assert result.exit_code == 2 + assert "rls create" in result.output + storage_service.list_tables.assert_not_called() + service.create_policy.assert_not_called() + + def test_json_mode_refuses_without_launching_picker(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + storage_service = MagicMock() + + result = _run( + ["--json", "rls", "setup", "--project", "prod"], + store, + service, + storage_service=storage_service, + ) + + assert result.exit_code == 2 + storage_service.list_tables.assert_not_called() + service.create_policy.assert_not_called() + + +class _Stream: + def __init__(self, tty: bool) -> None: + self._tty = tty + + def isatty(self) -> bool: + return self._tty + + +@pytest.mark.parametrize( + ("stdin_tty", "stdout_tty", "expected"), + [(True, True, True), (True, False, False), (False, True, False), (False, False, False)], +) +def test_setup_is_interactive_only_when_stdin_and_stdout_are_terminals( + monkeypatch: pytest.MonkeyPatch, stdin_tty: bool, stdout_tty: bool, expected: bool +) -> None: + """A TTY stdout with a piped stdin used to pass, so `rls setup` hit the API before the picker failed.""" + monkeypatch.setattr("sys.stdin", _Stream(stdin_tty)) + monkeypatch.setattr("sys.stdout", _Stream(stdout_tty)) + + assert _stdio_is_tty() is expected + + +class TestRlsReviewFixes: + def test_schema_auth_failure_is_an_error_not_a_missing_schema(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.fetch_schema.side_effect = KeboolaApiError( + message="bad token", status_code=401, error_code=ErrorCode.INVALID_TOKEN + ) + + result = _run(["--json", "rls", "schema", "--project", "prod"], store, service) + + assert result.exit_code == 3 # authentication, not 4 (NOT_FOUND) + assert "INVALID_TOKEN" in result.output + + def test_update_clear_target_projects_passes_an_empty_list(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.update_policy.return_value = {**_policy_row(), "preview": []} + + result = _run( + [ + "--json", + "rls", + "update", + "--project", + "prod", + "--policy-id", + "p-1", + "--clear-target-projects", + ], + store, + service, + ) + + assert result.exit_code == 0, result.output + assert service.update_policy.call_args.kwargs["target_projects"] == [] + + def test_update_clear_and_target_project_are_mutually_exclusive(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + + result = _run( + [ + "--json", "rls", "update", "--project", "prod", "--policy-id", "p-1", + "--clear-target-projects", "--target-project", "7", + ], + store, + service, + ) # fmt: skip + + assert result.exit_code == 2 + service.update_policy.assert_not_called() + + def test_update_without_the_flag_keeps_grants_unchanged(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.update_policy.return_value = {**_policy_row(), "preview": []} + + _run( + ["--json", "rls", "update", "--project", "prod", "--policy-id", "p-1", "--yes"], + store, + service, + ) + + assert service.update_policy.call_args.kwargs["target_projects"] is None + + def test_create_prints_the_validation_warning_in_human_mode(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = { + **_policy_row(), + "preview": [], + "warnings": ["Live schema validation was skipped: no schema"], + } + + result = _run( + [ + "rls", "create", "--project", "prod", "--table-id", "in.c-crm.invoices", "--dialect", "snowflake", + "--rules", '[{"principal":"a@x.com","condition":{"true":true}}]', "--yes", + ], + store, + service, + ) # fmt: skip + + assert result.exit_code == 0, result.output + assert "Live schema validation was skipped" in result.output + + +class TestRlsSetupReviewFixes: + """`rls setup` needs a terminal, so the interactive gate and the picker are patched out.""" + + @staticmethod + def _setup_run( + args: list[str], store: ConfigStore, service: MagicMock, storage: MagicMock + ) -> Any: + with ( + patch("keboola_agent_cli.commands.rls._stdio_is_tty", return_value=True), + patch("keboola_agent_cli.commands.rls.checkbox_select", return_value=[0, 1]), + ): + return _run(args, store, service, storage_service=storage) + + @staticmethod + def _storage() -> MagicMock: + storage = MagicMock() + storage.list_tables.return_value = { + "tables": [{"id": "in.c-crm.a", "rows_count": 1}, {"id": "in.c-crm.b", "rows_count": 2}] + } + return storage + + RULES = '[{"principal":"a@x.com","condition":{"true":true}}]' + + def test_an_invalid_dialect_is_rejected_before_any_api_call(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + storage = self._storage() + + result = self._setup_run( + [ + "rls", + "setup", + "--project", + "prod", + "--dialect", + "postgres", + "--rules", + self.RULES, + "--yes", + ], + store, + MagicMock(), + storage, + ) + + assert result.exit_code == 2 + storage.list_tables.assert_not_called() + + def test_an_exact_create_denial_also_blocks_setup(self, tmp_path: Path) -> None: + from keboola_agent_cli.models import PermissionPolicy + + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + cfg = store.load() + cfg.permissions = PermissionPolicy(mode="allow", deny=["rls.create"]) + store.save(cfg) + service = MagicMock() + storage = self._storage() + + result = self._setup_run( + [ + "rls", + "setup", + "--project", + "prod", + "--dialect", + "snowflake", + "--rules", + self.RULES, + "--yes", + ], + store, + service, + storage, + ) + + assert result.exit_code == 6, result.output + storage.list_tables.assert_not_called() + service.create_policy.assert_not_called() + + @pytest.mark.parametrize( + ("error", "exit_code", "error_code"), + [ + ( + KeboolaApiError(message="x", status_code=401, error_code=ErrorCode.INVALID_TOKEN), + 3, + "INVALID_TOKEN", + ), + ( + KeboolaApiError(message="x", status_code=500, error_code=ErrorCode.API_ERROR), + 1, + "API_ERROR", + ), + (ConfigError("no such project"), 5, "CONFIG_ERROR"), + ], + ) + def test_every_table_failing_keeps_the_error_class_in_the_exit_code( + self, tmp_path: Path, error: Exception, exit_code: int, error_code: str + ) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.side_effect = error + + with patch("keboola_agent_cli.commands.rls.get_formatter") as get_formatter_mock: + real = OutputFormatter(json_mode=False) + get_formatter_mock.return_value = real + with patch.object(real, "error", wraps=real.error) as error_spy: + result = self._setup_run( + [ + "rls", + "setup", + "--project", + "prod", + "--dialect", + "snowflake", + "--rules", + self.RULES, + "--yes", + ], + store, + service, + self._storage(), + ) + + assert result.exit_code == exit_code + assert error_spy.call_args.kwargs["error_code"] == error_code # the failures' own code + + def test_every_table_failing_is_a_non_zero_exit(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.side_effect = KeboolaApiError( + message="needs a master token", + status_code=403, + error_code=ErrorCode.MISSING_MASTER_TOKEN, + ) + + result = self._setup_run( + [ + "rls", + "setup", + "--project", + "prod", + "--dialect", + "snowflake", + "--rules", + self.RULES, + "--yes", + ], + store, + service, + self._storage(), + ) + + assert result.exit_code == 3 # an auth-class failure keeps the authentication exit code + assert "2 of 2 RLS policies could not be created" in result.output + + def test_a_partial_failure_is_a_non_zero_exit_and_names_the_failed_table( + self, tmp_path: Path + ) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + ok = {**_policy_row(), "preview": []} + failure = KeboolaApiError(message="boom", status_code=500, error_code=ErrorCode.API_ERROR) + # The wizard previews each table (dry-run) first, then writes: only the second write fails. + service.create_policy.side_effect = [ok, ok, ok, failure] + + result = self._setup_run( + [ + "rls", + "setup", + "--project", + "prod", + "--dialect", + "snowflake", + "--rules", + self.RULES, + "--yes", + ], + store, + service, + self._storage(), + ) + + assert result.exit_code == 1 + assert "1 of 2 RLS policies could not be created: in.c-crm.b" in result.output + + def test_all_tables_created_exits_zero(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "preview": []} + + result = self._setup_run( + [ + "rls", + "setup", + "--project", + "prod", + "--dialect", + "snowflake", + "--rules", + self.RULES, + "--yes", + ], + store, + service, + self._storage(), + ) + + assert result.exit_code == 0, result.output + + +class TestScopeAndDestructiveGates: + """`--scope organization` and `delete` are destructive-class: `--deny-destructive` blocks them.""" + + RULES = '[{"principal":"a@x.com","condition":{"true":true}}]' + + @pytest.mark.parametrize(("extra", "exit_code"), [([], 0), (["--scope", "organization"], 6)]) + def test_deny_destructive_blocks_only_organization_scope( + self, tmp_path: Path, extra: list[str], exit_code: int + ) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "preview": []} + + result = _run( + [ + "--json", "--deny-destructive", "rls", "create", "--project", "prod", + "--table-id", "in.c-crm.t", "--rules", self.RULES, *extra, + ], + store, + service, + ) # fmt: skip + + assert result.exit_code == exit_code, result.output + assert service.create_policy.called is (exit_code == 0) + + def test_scope_is_passed_to_the_service(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.return_value = {**_policy_row(), "preview": []} + + _run( + [ + "--json", "rls", "create", "--project", "prod", "--table-id", "in.c-crm.t", + "--rules", self.RULES, "--scope", "organization", + ], + store, + service, + ) # fmt: skip + + kwargs = service.create_policy.call_args.kwargs + assert kwargs["scope"] == "organization" + assert kwargs["dialect"] is None # omitted: the service uses the project backend + + def test_deny_destructive_blocks_delete(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + + result = _run( + [ + "--json", + "--deny-destructive", + "rls", + "delete", + "--project", + "prod", + "--policy-id", + "p-1", + ], + store, + service, + ) + + assert result.exit_code == 6 + service.delete_policy.assert_not_called() + + def test_delete_dry_run_needs_no_confirm_and_deletes_nothing(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.delete_policy.return_value = { + "project": "prod", + "policy": _policy_row(), + "dry_run": True, + } + + result = _run( + ["rls", "delete", "--project", "prod", "--policy-id", "p-1", "--dry-run"], + store, + service, + ) + + assert result.exit_code == 0, result.output + assert "Would delete" in result.output + service.delete_policy.assert_called_once_with(alias="prod", policy_id="p-1", dry_run=True) + + def test_a_service_usage_error_exits_2(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service = MagicMock() + service.create_policy.side_effect = KeboolaApiError( + message="--target-project 'ghost' is neither ...", + status_code=400, + error_code=ErrorCode.INVALID_ARGUMENT, + ) + + result = _run( + [ + "--json", "rls", "create", "--project", "prod", "--table-id", "t.x", + "--rules", self.RULES, "--target-project", "ghost", + ], + store, + service, + ) # fmt: skip + + assert result.exit_code == 2 + + @pytest.mark.parametrize( + "command", [["rls", "setup"], ["rls", "create", "--table-id", "t.x", "--rules", "[]"]] + ) + def test_organization_scope_with_target_project_fails_before_any_call( + self, tmp_path: Path, command: list[str] + ) -> None: + """No grants on organization scope: a usage error before the table listing or any write.""" + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + service, storage = MagicMock(), MagicMock() + args = [*command, "--project", "prod", "--scope", "organization", "--target-project", "7"] + + result = _run(args, store, service, storage_service=storage) + + assert result.exit_code == 2, result.output + assert "--target-project requires --scope targeted" in result.output + storage.list_tables.assert_not_called() + service.create_policy.assert_not_called() + + def test_setup_json_prints_a_json_error_envelope(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + + result = _run(["--json", "rls", "setup", "--project", "prod"], store, MagicMock()) + + assert result.exit_code == 2 + envelope = json.loads(result.output) + assert envelope["status"] == "error" + assert envelope["error"]["code"] == "INVALID_ARGUMENT" + assert "rls create" in envelope["error"]["message"] diff --git a/tests/test_rls_service.py b/tests/test_rls_service.py new file mode 100644 index 00000000..ec0b1c21 --- /dev/null +++ b/tests/test_rls_service.py @@ -0,0 +1,1131 @@ +"""Service-layer tests for ``RlsService`` and the ``_rls_condition`` helpers. + +Each test injects a ``unittest.mock.MagicMock`` as the metastore client +factory so we verify orchestration (scope resolution, validation order, call +sequencing) without touching HTTP -- mirrors ``test_semantic_layer_service.py``. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, ClassVar +from unittest.mock import ANY, MagicMock + +import pytest + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ErrorCode, KeboolaApiError +from keboola_agent_cli.json_utils import draft7_errors +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services._rls_condition import ( + compile_condition_preview, + validate_condition_ops, + validate_rules_local, +) +from keboola_agent_cli.services.rls_service import RlsService + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def _make_store(tmp_path: Path, alias: str = "prod") -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + store.add_project( + alias, + ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + project_name=alias, + project_id=5725, + ), + ) + return store + + +def _make_service( + store: ConfigStore, *, metastore_mock: MagicMock | None = None +) -> tuple[RlsService, MagicMock]: + mock = metastore_mock or MagicMock() + mock.__enter__ = MagicMock(return_value=mock) + mock.__exit__ = MagicMock(return_value=False) + service = RlsService( + config_store=store, + client_factory=storage_client_factory(), + metastore_client_factory=lambda url, token: mock, + ) + mock.list_items.return_value = [] + return service, mock + + +def storage_client_factory(backend: str = "snowflake") -> Any: + """A Storage client factory whose token belongs to a project on ``backend``.""" + storage = MagicMock() + storage.__enter__ = MagicMock(return_value=storage) + storage.__exit__ = MagicMock(return_value=False) + storage.verify_token.return_value.default_backend = backend + return lambda url, token: storage + + +def _policy_item( + item_id: str = "p-1", + table: str = "in.c-crm.invoices", + dialect: str = "snowflake", + rules: list[dict[str, Any]] | None = None, + scope: str = "organization", + source_project_id: str | None = "5725", + target_project_ids: list[Any] | None = None, +) -> dict[str, Any]: + return { + "type": "rls-policy", + "id": item_id, + "attributes": { + "table": table, + "dialect": dialect, + "rules": rules + if rules is not None + else [{"principal": "a@x.com", "condition": {"true": True}}], + }, + "meta": { + "scope": scope, + "sourceProjectId": source_project_id, + "targetProjectIds": target_project_ids or [], + "revision": 1, + }, + } + + +_RULES = [{"principal": "a@x.com", "condition": {"column": "region", "op": "eq", "value": "EU"}}] + +# `1 == True` and any non-True value would otherwise read as "true": only `{"true": true}` is the sentinel. +_BAD_TRUE_SENTINELS = [ + {"true": False}, + {"true": 0}, + {"true": 1}, + {"true": None}, + {"true": "yes"}, + {"true": True, "extra": 1}, +] + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + + +class TestListPolicies: + def test_returns_rows_from_list_items(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.list_items.return_value = [_policy_item()] + + result = service.list_policies("prod") + + mock.list_items.assert_called_once_with("rls-policy") + assert result["project"] == "prod" + assert result["policies"][0]["id"] == "p-1" + assert result["policies"][0]["rule_count"] == 1 + assert result["policies"][0]["scope"] == "organization" + + +class TestGetPolicy: + def test_returns_full_rules(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_item.return_value = _policy_item() + + result = service.get_policy("prod", "p-1") + + mock.get_item.assert_called_once_with("rls-policy", "p-1") + assert result["rules"] == [{"principal": "a@x.com", "condition": {"true": True}}] + assert result["revision"] == 1 + + +class TestFetchSchema: + def test_success(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_schema.return_value = {"type": "object"} + + fetch = service.fetch_schema("prod") + + assert fetch.schema == {"type": "object"} + assert fetch.reason is None + + def test_api_error_degrades(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_schema.side_effect = KeboolaApiError( + message="not found", status_code=404, error_code=ErrorCode.NOT_FOUND + ) + + fetch = service.fetch_schema("prod") + + assert fetch.schema is None + assert fetch.reason == "not found" + + @pytest.mark.parametrize( + ("error_code", "status"), + [ + (ErrorCode.INVALID_TOKEN, 401), + (ErrorCode.MISSING_MASTER_TOKEN, 403), + (ErrorCode.ACCESS_DENIED, 403), + (ErrorCode.PERMISSION_DENIED, 403), + (ErrorCode.API_ERROR, 401), # by status alone + (ErrorCode.API_ERROR, 403), + ], + ) + def test_auth_and_permission_errors_are_not_reported_as_a_missing_schema( + self, tmp_path: Path, error_code: ErrorCode, status: int + ) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_schema.side_effect = KeboolaApiError( + message="nope", status_code=status, error_code=error_code + ) + + with pytest.raises(KeboolaApiError) as excinfo: + service.fetch_schema("prod") + + assert excinfo.value.error_code == error_code + + @pytest.mark.parametrize("status", [404, 500, 502]) + def test_other_api_errors_still_degrade(self, tmp_path: Path, status: int) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_schema.side_effect = KeboolaApiError( + message="unavailable", status_code=status, error_code=ErrorCode.API_ERROR + ) + + fetch = service.fetch_schema("prod") + + assert fetch.schema is None + assert fetch.reason == "unavailable" + + @pytest.mark.parametrize( + "malformed", [{"type": 123}, {"properties": {"table": {"type": "nope"}}}] + ) + def test_a_non_empty_but_malformed_schema_degrades_instead_of_raising( + self, tmp_path: Path, malformed: dict + ) -> None: + """`Draft7Validator(schema)` would raise SchemaError mid-validation: report it as unavailable.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.return_value = malformed + mock.post_item.return_value = _policy_item() + + fetch = service.fetch_schema("prod") + assert fetch.schema is None + assert "malformed" in (fetch.reason or "") + + result = service.create_policy("prod", table="in.c-crm.t", rules=_RULES) + assert any("malformed" in w for w in result["warnings"]) + mock.post_item.assert_called_once() + + def test_unexpected_error_degrades(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_schema.side_effect = RuntimeError("boom") + + fetch = service.fetch_schema("prod") + + assert fetch.schema is None + assert "boom" in (fetch.reason or "") + + def test_empty_schema_degrades(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + mock.get_schema.return_value = {} + + fetch = service.fetch_schema("prod") + + assert fetch.schema is None + assert fetch.reason is not None + + +# --------------------------------------------------------------------------- +# Write: create +# --------------------------------------------------------------------------- + + +class TestCreatePolicy: + def test_default_scope_is_targeted_with_no_grants(self, tmp_path: Path) -> None: + """The schema's own default: only the owning project is governed until grants are added.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.post_item.return_value = _policy_item(scope="targeted") + + service.create_policy("prod", table="in.c-crm.invoices", rules=_RULES) + + kwargs = mock.post_item.call_args.kwargs + assert kwargs["scope"] == "targeted" + assert kwargs["target_project_ids"] is None + assert kwargs["data"]["dialect"] == "snowflake" # defaulted from the project backend + mock.put_target_projects.assert_not_called() + + def test_organization_scope_only_when_asked_for(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.post_item.return_value = _policy_item() + + service.create_policy("prod", table="t.x", rules=_RULES, scope="organization") + + assert mock.post_item.call_args.kwargs["scope"] == "organization" + + @pytest.mark.parametrize( + ("kwargs", "fragment"), + [ + ({"scope": "project"}, "scope must be one of"), + ({"scope": "organization", "target_projects": ["7"]}, "require scope 'targeted'"), + ], + ) + def test_bad_scope_combinations_are_usage_errors( + self, tmp_path: Path, kwargs: dict, fragment: str + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + + with pytest.raises(KeboolaApiError, match=fragment) as excinfo: + service.create_policy("prod", table="t.x", rules=_RULES, **kwargs) + + assert excinfo.value.error_code == ErrorCode.INVALID_ARGUMENT + mock.post_item.assert_not_called() + + def test_target_projects_go_in_the_create_request_only(self, tmp_path: Path) -> None: + """The POST stores the grants; a second grants call could fail after the policy exists.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.post_item.return_value = _policy_item(scope="targeted", target_project_ids=[999]) + + service.create_policy("prod", table="t.x", rules=_RULES, target_projects=["999", "999"]) + + assert mock.post_item.call_args.kwargs["target_project_ids"] == [999] # de-duplicated + mock.put_target_projects.assert_not_called() + + @pytest.mark.parametrize( + ("backend", "dialect"), [("snowflake", "bigquery"), ("bigquery", "snowflake")] + ) + def test_a_dialect_other_than_the_backend_is_refused( + self, tmp_path: Path, backend: str, dialect: str + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + service._client_factory = storage_client_factory(backend) + + with pytest.raises(KeboolaApiError, match="does not match the project backend") as excinfo: + service.create_policy("prod", table="t.x", dialect=dialect, rules=_RULES) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.post_item.assert_not_called() + + def test_a_backend_without_policy_support_is_refused(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + service._client_factory = storage_client_factory("synapse") + + with pytest.raises(KeboolaApiError, match="synapse"): + service.create_policy("prod", table="t.x", rules=_RULES) + + mock.post_item.assert_not_called() + + def test_a_duplicate_name_gets_a_policy_remedy_not_the_semantic_one( + self, tmp_path: Path + ) -> None: + """Policies are named by table: the 409 remedy names `rls update` / `rls delete`.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.post_item.return_value = _policy_item() + + service.create_policy("prod", table="t.x", rules=_RULES) + + hint = mock.post_item.call_args.kwargs["conflict_hint"] + assert "rls update --policy-id" in hint + assert "rls delete" in hint + + @pytest.mark.parametrize( + "bad_rules", + [ + [{"principal": "a@x.com", "condition": {"column": "x", "op": "bogus", "value": 1}}], + [{"principal": "a@x.com", "principals": ["b@x.com"], "condition": {"true": True}}], + ], + ) + def test_malformed_rules_are_rejected_before_any_write( + self, tmp_path: Path, bad_rules: list + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + + with pytest.raises(KeboolaApiError): + service.create_policy("prod", table="t.x", rules=bad_rules) + + mock.post_item.assert_not_called() + + def test_structural_validation_runs_when_schema_available(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + # A schema that requires a `description` field the candidate body lacks. + mock.get_schema.return_value = { + "type": "object", + "required": ["table", "dialect", "rules", "description"], + } + + with pytest.raises(KeboolaApiError) as excinfo: + service.create_policy("prod", table="t.x", rules=_RULES) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.post_item.assert_not_called() + + def test_dry_run_never_calls_post_item(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + + result = service.create_policy("prod", table="t.x", rules=_RULES, dry_run=True) + + mock.post_item.assert_not_called() + assert result["dry_run"] is True + assert result["scope"] == "targeted" + assert result["preview"][0]["condition"] == "\"region\" = 'EU'" + + +class TestSchema110: + """Policy schema 1.1.0: groups, OR-combined rules, `default`, the false sentinel, `$identity`.""" + + def test_one_identity_in_several_rules_is_accepted(self, tmp_path: Path) -> None: + """The 1.1.0 engine ORs the conditions of every rule an identity matches.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.post_item.return_value = _policy_item() + rules = [ + {"principal": "a@x.com", "condition": {"column": "r", "op": "eq", "value": "EU"}}, + {"principals": ["A@X.com"], "condition": {"column": "r", "op": "eq", "value": "US"}}, + ] + + service.create_policy("prod", table="t.x", rules=rules) + + mock.post_item.assert_called_once() + mock.list_items.assert_not_called() # no cross-policy principal scan any more + + @pytest.mark.parametrize( + "rule", + [ + {"groups": ["sales-eu", "Sales EU"], "condition": {"true": True}}, + {"groups": ["g"], "condition": {"false": True}}, + { + "groups": ["g"], + "condition": {"column": "o", "op": "eq", "value": {"$identity": "email"}}, + }, + { + "groups": ["g"], + "condition": {"column": "t", "op": "in", "values": {"$identity": "groups"}}, + }, + ], + ) + def test_valid_110_rules(self, rule: dict) -> None: + assert validate_rules_local([rule]) == [] + + @pytest.mark.parametrize( + ("rule", "fragment"), + [ + ({"groups": [], "condition": {"true": True}}, "groups must be a non-empty list"), + ({"groups": ["a\x00"], "condition": {"true": True}}, "control characters"), + ({"principal": "a", "groups": ["g"], "condition": {"true": True}}, "exactly one of"), + ({"groups": ["g"], "condition": {"false": False}}, 'exactly {"false": true}'), + ( + { + "groups": ["g"], + "condition": {"column": "c", "op": "eq", "value": {"$identity": "groups"}}, + }, + "string, number or boolean 'value'", + ), + ( + { + "groups": ["g"], + "condition": {"column": "c", "op": "in", "values": {"$identity": "email"}}, + }, + "non-empty list 'values'", + ), + ], + ) + def test_invalid_110_rules(self, rule: dict, fragment: str) -> None: + errors = validate_rules_local([rule]) + assert errors and fragment in errors[0] + + def test_default_is_validated_sent_and_previewed(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.post_item.return_value = _policy_item() + + result = service.create_policy("prod", table="t.x", rules=_RULES, default={"false": True}) + + assert mock.post_item.call_args.kwargs["data"]["default"] == {"false": True} + assert result["default_preview"] == "FALSE" + + def test_an_invalid_default_is_refused(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + + with pytest.raises(KeboolaApiError, match=r"default\.'false' condition"): + service.create_policy("prod", table="t.x", rules=_RULES, default={"false": 1}) + + mock.post_item.assert_not_called() + + def test_update_patches_the_default_and_keeps_a_missing_dialect(self, tmp_path: Path) -> None: + """A 1.1.0 policy may omit `dialect`: the merge falls back to the backend, not to ''.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + stored = _policy_item() + del stored["attributes"]["dialect"] + mock.get_item.return_value = stored + + service.update_policy("prod", "p-1", default={"true": True}) + + mock.patch_item.assert_called_once_with( + "rls-policy", "p-1", name=None, data={"default": {"true": True}}, conflict_hint=ANY + ) + + +# --------------------------------------------------------------------------- +# Write: update +# --------------------------------------------------------------------------- + + +class TestUpdatePolicy: + def test_patches_only_the_changed_keys_and_validates_the_merged_policy( + self, tmp_path: Path + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_item.return_value = _policy_item(table="in.c-crm.invoices", dialect="snowflake") + mock.get_schema.return_value = {"type": "object", "required": ["table", "dialect", "rules"]} + + service.update_policy("prod", "p-1", rules=_RULES) + + mock.patch_item.assert_called_once_with( + "rls-policy", "p-1", name=None, data={"rules": _RULES}, conflict_hint=ANY + ) + mock.put_item.assert_not_called() + + def test_a_new_table_renames_the_policy(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_item.return_value = _policy_item() + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + + service.update_policy("prod", "p-1", table="in.c-crm.new") + + mock.patch_item.assert_called_once_with( + "rls-policy", + "p-1", + name="in.c-crm.new", + data={"table": "in.c-crm.new"}, + conflict_hint=ANY, + ) + + def test_nothing_to_update_is_a_usage_error(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + + with pytest.raises(KeboolaApiError) as excinfo: + service.update_policy("prod", "p-1") + + assert excinfo.value.error_code == ErrorCode.INVALID_ARGUMENT + mock.get_item.assert_not_called() + + def test_the_result_is_re_read_after_the_write(self, tmp_path: Path) -> None: + """The grants change after the PATCH response was built -- report what is stored now.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + before = _policy_item(scope="targeted", target_project_ids=[7]) + after = _policy_item(scope="targeted", target_project_ids=[8]) + mock.get_item.side_effect = [before, after] + + result = service.update_policy("prod", "p-1", target_projects=["8"]) + + assert result["target_project_ids"] == [8] + + def test_grants_change_before_the_rules(self, tmp_path: Path) -> None: + """Granting is organization-admin only: when it is refused, the rules stay unwritten.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.get_item.return_value = _policy_item(scope="targeted") + mock.put_target_projects.side_effect = KeboolaApiError( + message="Insufficient permissions", status_code=403, error_code=ErrorCode.ACCESS_DENIED + ) + + with pytest.raises(KeboolaApiError): + service.update_policy("prod", "p-1", rules=_RULES, target_projects=["8"]) + + mock.patch_item.assert_not_called() + + def test_an_organization_policy_cannot_be_narrowed_to_target_projects( + self, tmp_path: Path + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_item.return_value = _policy_item(scope="organization") + + with pytest.raises(KeboolaApiError) as excinfo: + service.update_policy("prod", "p-1", target_projects=["42"]) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.patch_item.assert_not_called() + mock.put_target_projects.assert_not_called() + + def test_a_dialect_other_than_the_backend_is_refused(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_item.return_value = _policy_item() + + with pytest.raises(KeboolaApiError, match="does not match the project backend"): + service.update_policy("prod", "p-1", dialect="bigquery") + + mock.patch_item.assert_not_called() + + def test_dry_run_never_writes(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_item.return_value = _policy_item(scope="targeted", target_project_ids=[7]) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + + result = service.update_policy("prod", "p-1", rules=_RULES, dry_run=True) + + mock.patch_item.assert_not_called() + mock.put_target_projects.assert_not_called() + assert result["dry_run"] is True + assert result["target_project_ids"] == [7] + + +class TestGrants: + """`target_projects`: None keeps the grants, a list replaces them, [] revokes them all.""" + + @pytest.mark.parametrize( + ("targets", "expected_call"), + [(None, None), ([], []), (["7", "8,9"], [7, 8, 9])], + ) + def test_target_projects_semantics( + self, tmp_path: Path, targets: list | None, expected_call: list | None + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + mock.get_item.return_value = _policy_item(scope="targeted", target_project_ids=[7]) + + service.update_policy("prod", "p-1", rules=_RULES, target_projects=targets) + + if expected_call is None: + mock.put_target_projects.assert_not_called() + else: + mock.put_target_projects.assert_called_once_with("rls-policy", "p-1", expected_call) + + def test_a_registered_alias_resolves_to_its_project_id(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + store.add_project( + "shared", + ProjectConfig( + stack_url="https://connection.keboola.com", token=TEST_TOKEN, project_id=77 + ), + ) + service, mock = _make_service(store) + mock.get_schema.side_effect = KeboolaApiError(message="n/a", status_code=404) + + result = service.create_policy( + "prod", table="t.x", rules=_RULES, target_projects=["shared"], dry_run=True + ) + + assert result["target_project_ids"] == [77] + + @pytest.mark.parametrize("bad", [["abc"], ["0"], ["-3"], ["1", "x"]]) + def test_a_bad_target_is_rejected_before_any_network_call( + self, tmp_path: Path, bad: list + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + + for call in ( + lambda: service.create_policy( + "prod", table="t.x", rules=_RULES, target_projects=bad, dry_run=True + ), + lambda: service.update_policy("prod", "p-1", target_projects=bad, dry_run=True), + ): + with pytest.raises(KeboolaApiError) as excinfo: + call() + assert excinfo.value.error_code == ErrorCode.INVALID_ARGUMENT + mock.get_item.assert_not_called() + mock.post_item.assert_not_called() + + +# --------------------------------------------------------------------------- +# Write: delete +# --------------------------------------------------------------------------- + + +class TestDeletePolicy: + def test_calls_delete_item(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + + result = service.delete_policy("prod", "p-1") + + mock.delete_item.assert_called_once_with("rls-policy", "p-1") + assert result == {"project": "prod", "policy_id": "p-1", "deleted": True} + + def test_dry_run_shows_the_policy_and_deletes_nothing(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_item.return_value = _policy_item() + + result = service.delete_policy("prod", "p-1", dry_run=True) + + mock.delete_item.assert_not_called() + assert result["dry_run"] is True + assert result["policy"]["id"] == "p-1" + + +# --------------------------------------------------------------------------- +# Pure condition helpers +# --------------------------------------------------------------------------- + + +class TestCompileConditionPreview: + def test_true_sentinel(self) -> None: + assert compile_condition_preview({"true": True}, "snowflake") == "TRUE" + + @pytest.mark.parametrize("bad", _BAD_TRUE_SENTINELS) + def test_non_true_sentinel_is_never_rendered_as_always_true(self, bad: dict) -> None: + with pytest.raises(ValueError, match="exactly"): + compile_condition_preview(bad, "snowflake") + + @pytest.mark.parametrize( + ("condition", "dialect", "sql"), + [ + *( + ( + {"column": "region", "op": op, "value": "EU"}, + "snowflake", + f"\"region\" {sql} 'EU'", + ) + for op, sql in [ + ("eq", "="), + ("ne", "!="), + ("gt", ">"), + ("gte", ">="), + ("lt", "<"), + ("lte", "<="), + ] + ), + # Quoted per dialect, like the enforcement (so a column-case mismatch is visible). + ({"column": "Region", "op": "eq", "value": "EU"}, "bigquery", "`Region` = 'EU'"), + ( + {"column": "region", "op": "in", "values": ["EU", "US"]}, + "snowflake", + "\"region\" IN ('EU', 'US')", + ), + ( + {"column": "region", "op": "not_in", "values": ["EU"]}, + "snowflake", + "\"region\" NOT IN ('EU')", + ), + ({"column": "d", "op": "is_null"}, "snowflake", '"d" IS NULL'), + ({"column": "d", "op": "is_not_null"}, "snowflake", '"d" IS NOT NULL'), + ({"column": "amount", "op": "gt", "value": 100}, "snowflake", '"amount" > 100'), + ({"column": "active", "op": "eq", "value": True}, "snowflake", '"active" = TRUE'), + ({"false": True}, "snowflake", "FALSE"), + ( + {"column": "o", "op": "eq", "value": {"$identity": "email"}}, + "snowflake", + '"o" = ', + ), + ( + {"column": "t", "op": "in", "values": {"$identity": "groups"}}, + "bigquery", + "`t` IN ()", + ), + ({"column": "o", "op": "eq", "value": "O'Brien"}, "snowflake", "\"o\" = 'O''Brien'"), + ( + { + "and": [ + {"column": "r", "op": "eq", "value": "EU"}, + {"column": "s", "op": "ne", "value": "x"}, + ] + }, + "snowflake", + "(\"r\" = 'EU') AND (\"s\" != 'x')", + ), + ( + { + "or": [ + {"column": "r", "op": "eq", "value": "EU"}, + {"column": "r", "op": "eq", "value": "US"}, + ] + }, + "snowflake", + "(\"r\" = 'EU') OR (\"r\" = 'US')", + ), + ], + ) + def test_renders_like_the_enforcement(self, condition: dict, dialect: str, sql: str) -> None: + assert compile_condition_preview(condition, dialect) == sql + + def test_unrecognized_shape_raises(self) -> None: + with pytest.raises(ValueError): + compile_condition_preview({"nonsense": True}, "snowflake") + + +class TestValidateConditionOps: + def test_known_op_passes(self) -> None: + assert validate_condition_ops({"column": "x", "op": "eq", "value": 1}) == [] + + def test_unknown_op_reported(self) -> None: + errors = validate_condition_ops({"column": "x", "op": "bogus", "value": 1}) + assert errors and "bogus" in errors[0] + + @pytest.mark.parametrize("op", [[], {}, 1, None]) + def test_a_non_string_op_is_reported_not_a_crash(self, op: object) -> None: + errors = validate_condition_ops({"column": "id", "op": op, "value": 1}) + + assert errors and "unknown condition op" in errors[0] + + def test_true_sentinel_always_valid(self) -> None: + assert validate_condition_ops({"true": True}) == [] + + @pytest.mark.parametrize("bad", _BAD_TRUE_SENTINELS) + def test_true_sentinel_must_be_exactly_true(self, bad: dict) -> None: + """`{"true": false}` must not pass: it would silently author an always-true policy.""" + errors = validate_condition_ops(bad) + assert errors and "exactly" in errors[0] + + def test_a_false_sentinel_is_rejected_before_any_write(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, mock = _make_service(store) + rules = [{"principal": "a@x.com", "condition": {"true": False}}] + + with pytest.raises(KeboolaApiError) as excinfo: + service.create_policy("prod", table="t", dialect="snowflake", rules=rules) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.post_item.assert_not_called() + + def test_and_requires_at_least_two(self) -> None: + errors = validate_condition_ops({"and": [{"true": True}]}) + assert errors + + def test_nested_and_recurses(self) -> None: + condition = { + "and": [ + {"column": "x", "op": "bogus", "value": 1}, + {"true": True}, + ] + } + errors = validate_condition_ops(condition) + assert errors and "bogus" in errors[0] + + +class TestValidateRulesLocal: + def test_empty_rules_rejected(self) -> None: + assert validate_rules_local([]) != [] + + def test_non_list_rejected(self) -> None: + assert validate_rules_local("not-a-list") != [] + + def test_valid_single_principal(self) -> None: + assert validate_rules_local([{"principal": "a@x.com", "condition": {"true": True}}]) == [] + + def test_valid_principals_list(self) -> None: + rules = [{"principals": ["a@x.com", "b@x.com"], "condition": {"true": True}}] + assert validate_rules_local(rules) == [] + + def test_neither_principal_nor_principals_rejected(self) -> None: + errors = validate_rules_local([{"condition": {"true": True}}]) + assert errors + + def test_both_principal_and_principals_rejected(self) -> None: + rules = [{"principal": "a@x.com", "principals": ["b@x.com"], "condition": {"true": True}}] + assert validate_rules_local(rules) + + def test_missing_condition_rejected(self) -> None: + errors = validate_rules_local([{"principal": "a@x.com"}]) + assert errors + + +class TestValidatePolicyStructural: + def test_valid_policy_passes(self) -> None: + schema = { + "type": "object", + "required": ["table", "dialect", "rules"], + "properties": { + "table": {"type": "string"}, + "dialect": {"enum": ["snowflake", "bigquery"]}, + "rules": {"type": "array", "minItems": 1}, + }, + } + policy = {"table": "in.c-x.y", "dialect": "snowflake", "rules": [{"condition": {}}]} + assert draft7_errors(policy, schema) == [] + + def test_missing_required_field_reported(self) -> None: + schema = {"type": "object", "required": ["table", "dialect", "rules"]} + errors = draft7_errors({"table": "t"}, schema) + assert errors + + +class TestValidationWarnings: + def test_a_skipped_live_validation_is_reported_not_hidden(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="no schema", status_code=404) + mock.post_item.return_value = _policy_item() + + result = service.create_policy("prod", table="in.c-crm.t", rules=_RULES) + + assert result["warnings"] == ["Live schema validation was skipped: no schema"] + + def test_dry_run_reports_it_too(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="no schema", status_code=404) + + result = service.create_policy("prod", table="in.c-crm.t", rules=_RULES, dry_run=True) + + assert result["warnings"] == ["Live schema validation was skipped: no schema"] + + def test_no_warning_when_the_schema_was_available(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.return_value = {"type": "object"} + mock.post_item.return_value = _policy_item() + + result = service.create_policy("prod", table="in.c-crm.t", rules=_RULES) + + assert "warnings" not in result + + def test_update_reports_it_as_well(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="no schema", status_code=404) + mock.get_item.return_value = _policy_item() + + result = service.update_policy("prod", "p-1", rules=_RULES) + + assert result["warnings"] == ["Live schema validation was skipped: no schema"] + + +class TestPrimitiveShape: + """The shape of a leaf condition is checked locally, so it fails as INVALID_RLS_POLICY even when the + live schema is unavailable (the documented state on stacks without the object type).""" + + @pytest.mark.parametrize( + ("condition", "fragment"), + [ + ({"column": "", "op": "eq", "value": 1}, "non-empty string 'column'"), + ({"op": "eq", "value": 1}, "non-empty string 'column'"), + ({"column": 5, "op": "is_null"}, "non-empty string 'column'"), + ({"column": "a", "op": "eq"}, "needs a 'value'"), + ({"column": "a", "op": "gt"}, "needs a 'value'"), + ({"column": "a", "op": "in"}, "non-empty list 'values'"), + ({"column": "a", "op": "not_in", "values": []}, "non-empty list 'values'"), + ({"column": "a", "op": "in", "values": "abc"}, "non-empty list 'values'"), + # `col = NULL` / `IN (NULL)` match no row: the principal would silently see nothing. + ({"column": "a", "op": "eq", "value": None}, "use op 'is_null'"), + ({"column": "a", "op": "in", "values": [1, None]}, "cannot list null"), + # Only scalar literals: an object/array must not slip through when the live schema is unavailable. + ({"column": "a", "op": "eq", "value": {"x": 1}}, "string, number or boolean 'value'"), + ({"column": "a", "op": "gt", "value": [1]}, "string, number or boolean 'value'"), + ({"column": "a", "op": "in", "values": [1, [2]]}, "string, number or boolean 'values'"), + ], + ) + def test_malformed_primitives_are_rejected(self, condition: dict, fragment: str) -> None: + errors = validate_condition_ops(condition) + assert errors and fragment in errors[0] + + @pytest.mark.parametrize( + "condition", + [ + {"column": "a", "op": "eq", "value": 0}, + {"column": "a", "op": "in", "values": [1]}, + {"column": "a", "op": "is_null"}, + { + "and": [ + {"column": "a", "op": "eq", "value": 1}, + {"column": "b", "op": "is_not_null"}, + ] + }, + ], + ) + def test_well_formed_primitives_pass(self, condition: dict) -> None: + assert validate_condition_ops(condition) == [] + + def test_a_malformed_rule_never_reaches_the_preview_or_the_write(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="no schema", status_code=404) + rules = [{"principal": "a@x.com", "condition": {"column": "", "op": "eq"}}] + + with pytest.raises(KeboolaApiError) as excinfo: + service.create_policy("prod", table="t", dialect="snowflake", rules=rules, dry_run=True) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.post_item.assert_not_called() + + +class TestCompositionAndKeysAreExact: + """One composition key, and no keys beyond an op's own shape: nothing may be validated and then dropped.""" + + _LEAF: ClassVar[dict[str, Any]] = {"column": "a", "op": "eq", "value": 1} + + @pytest.mark.parametrize( + "bad", + [ + { + "and": [_LEAF, _LEAF], + "or": [_LEAF, _LEAF], + }, # the second branch used to be silently dropped + {"or": [_LEAF, _LEAF], "and": [_LEAF, _LEAF]}, + {"and": [_LEAF, _LEAF], "column": "a"}, + {"or": [_LEAF, _LEAF], "op": "eq"}, + ], + ) + def test_a_condition_with_several_composition_keys_is_rejected(self, bad: dict) -> None: + errors = validate_condition_ops(bad) + assert errors and "exactly one of those keys" in errors[0] + with pytest.raises(ValueError, match="exactly one of those keys"): + compile_condition_preview(bad, "snowflake") + + @pytest.mark.parametrize( + ("condition", "extra"), + [ + ({"column": "a", "op": "eq", "value": 1, "values": [1]}, ["values"]), + ({"column": "a", "op": "in", "values": [1], "value": 1}, ["value"]), + ({"column": "a", "op": "is_null", "value": 1}, ["value"]), + ({"column": "a", "op": "eq", "value": 1, "note": "x"}, ["note"]), + ], + ) + def test_keys_beyond_the_ops_shape_are_rejected(self, condition: dict, extra: list) -> None: + errors = validate_condition_ops(condition) + assert errors and f"unexpected keys {extra}" in errors[0] + + def test_a_valid_nested_tree_still_passes_and_renders(self) -> None: + tree = { + "or": [ + {"and": [self._LEAF, {"column": "b", "op": "in", "values": [1, 2]}]}, + {"column": "c", "op": "is_null"}, + ] + } + assert validate_condition_ops(tree) == [] + assert "OR" in compile_condition_preview(tree, "snowflake") + + def test_a_multi_key_condition_never_reaches_a_write(self, tmp_path: Path) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="no schema", status_code=404) + rules = [ + { + "principal": "a@x.com", + "condition": {"and": [self._LEAF, self._LEAF], "or": [self._LEAF, self._LEAF]}, + } + ] + + with pytest.raises(KeboolaApiError) as excinfo: + service.create_policy("prod", table="t", dialect="snowflake", rules=rules) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.post_item.assert_not_called() + + +class TestPrincipalFieldsAreValidatedByValueNotTruthiness: + _COND: ClassVar[dict[str, Any]] = {"true": True} + + @pytest.mark.parametrize( + ("rule", "fragment"), + [ + ({"principal": "a", "principals": [], "condition": _COND}, "exactly one of"), + ({"principal": "", "principals": ["b"], "condition": _COND}, "exactly one of"), + ({"principal": 1, "condition": _COND}, "principal must be a non-empty string"), + ({"principal": "", "condition": _COND}, "principal must be a non-empty string"), + ({"principal": None, "condition": _COND}, "principal must be a non-empty string"), + ({"principal": ["a"], "condition": _COND}, "principal must be a non-empty string"), + ({"principals": [], "condition": _COND}, "principals must be a non-empty list"), + ({"principals": "a@x.com", "condition": _COND}, "principals must be a non-empty list"), + ({"principals": ["a", ""], "condition": _COND}, "principals must be a non-empty list"), + ({"principals": ["a", 2], "condition": _COND}, "principals must be a non-empty list"), + ({"condition": _COND}, "exactly one of"), + # The wildcard must be the sole entry of its list (schema 1.1.0). + ({"principals": ["*", "a@x.com"], "condition": _COND}, "must be the only entry"), + ({"principals": ["a@x.com", "*"], "condition": _COND}, "must be the only entry"), + ({"principals": ["*", "*"], "condition": _COND}, "must be the only entry"), + ({"groups": ["*", "sales"], "condition": _COND}, "must be the only entry"), + ({"groups": [], "condition": _COND}, "groups must be a non-empty list"), + ], + ) + def test_malformed_principal_fields_are_rejected_locally( + self, rule: dict, fragment: str + ) -> None: + errors = validate_rules_local([rule]) + assert errors and fragment in errors[0] + + @pytest.mark.parametrize( + "rule", + [ + {"principal": "a@x.com", "condition": _COND}, + {"principals": ["a@x.com", "b@x.com"], "condition": _COND}, + {"principal": "*", "condition": _COND}, + {"principals": ["*"], "condition": _COND}, + {"groups": ["*"], "condition": _COND}, + ], + ) + def test_well_formed_principal_fields_pass(self, rule: dict) -> None: + assert validate_rules_local([rule]) == [] + + @pytest.mark.parametrize( + ("schema_version", "blocked"), + [("1.0.0", True), ("1.1.0", False), ("1.2.0", False), (None, False), ("v1", False)], + ) + def test_wildcard_is_refused_on_a_schema_older_than_1_1_0( + self, tmp_path: Path, schema_version: str | None, blocked: bool + ) -> None: + """In a 1.0.0 object `*` is a literal no email equals: it would silently match nobody.""" + service, mock = _make_service(_make_store(tmp_path)) + schema: dict[str, Any] = {"type": "object"} + if schema_version: + schema["version"] = schema_version + mock.get_schema.return_value = schema + mock.post_item.return_value = _policy_item() + rules = [{"principal": "*", "condition": self._COND}] + + if blocked: + with pytest.raises(KeboolaApiError) as excinfo: + service.create_policy("prod", table="t.x", rules=rules) + assert "needs rls-policy schema 1.1.0" in excinfo.value.message + mock.post_item.assert_not_called() + else: + service.create_policy("prod", table="t.x", rules=rules) + mock.post_item.assert_called_once() + + def test_a_policy_without_a_wildcard_is_not_blocked_by_an_old_schema( + self, tmp_path: Path + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.return_value = {"type": "object", "version": "1.0.0"} + mock.post_item.return_value = _policy_item() + + service.create_policy( + "prod", table="t.x", rules=[{"principal": "a@x.com", "condition": self._COND}] + ) + + mock.post_item.assert_called_once() + + def test_a_malformed_principal_never_reaches_a_write_when_the_schema_is_unavailable( + self, tmp_path: Path + ) -> None: + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.side_effect = KeboolaApiError(message="no schema", status_code=404) + + with pytest.raises(KeboolaApiError) as excinfo: + service.create_policy( + "prod", + table="t", + dialect="snowflake", + rules=[{"principal": 1, "condition": self._COND}], + ) + + assert excinfo.value.error_code == ErrorCode.INVALID_RLS_POLICY + mock.post_item.assert_not_called() + + +class TestUnresolvedVersionListing: + @pytest.mark.parametrize("listing", [{"versions": []}, {"versions": [{"isDefault": True}]}]) + def test_a_version_listing_that_cannot_be_resolved_is_unavailable_not_a_schema( + self, tmp_path: Path, listing: dict + ) -> None: + """It is a valid, constraint-free JSON Schema, so validating against it would silently skip every + structural check without the documented warning.""" + service, mock = _make_service(_make_store(tmp_path)) + mock.get_schema.return_value = listing + mock.post_item.return_value = _policy_item() + + fetch = service.fetch_schema("prod") + assert fetch.schema is None + assert "version listing" in (fetch.reason or "") + + result = service.create_policy("prod", table="in.c-crm.t", rules=_RULES) + assert any("version listing" in w for w in result["warnings"]) diff --git a/tests/test_semantic_layer_scope.py b/tests/test_semantic_layer_scope.py index a16a6072..bda4a5ea 100644 --- a/tests/test_semantic_layer_scope.py +++ b/tests/test_semantic_layer_scope.py @@ -97,7 +97,9 @@ def test_aliases_ids_and_comma_lists_resolve_and_dedupe(self, tmp_path: Path) -> @pytest.mark.parametrize( ("target", "needle"), [ - ("ghost", "neither a registered project alias nor a numeric project ID"), + ("ghost", "neither a registered project alias nor a positive numeric project ID"), + ("0", "neither a registered project alias nor a positive numeric project ID"), + ("²", "neither a registered project alias nor a positive numeric project ID"), ("eu-project", "different stack"), ], ) diff --git a/tests/test_server_cls.py b/tests/test_server_cls.py new file mode 100644 index 00000000..86233028 --- /dev/null +++ b/tests/test_server_cls.py @@ -0,0 +1,181 @@ +"""Tests for `server/routers/cls.py` -- router -> service call parity + permission gating. + +Mirrors ``test_server_rls.py``: kwarg parity via a mocked ``ServiceRegistry`` +and persisted-policy -> 403. Same classes as RLS: create/update ``admin``, +delete ``destructive``. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +if importlib.util.find_spec("fastapi") is None: # pragma: no cover + pytest.skip( + "FastAPI not installed; run `uv pip install -e '.[server]'`", allow_module_level=True + ) + +from fastapi.testclient import TestClient + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ErrorCode, KeboolaApiError +from keboola_agent_cli.models import PermissionPolicy +from keboola_agent_cli.server import create_app +from keboola_agent_cli.server.dependencies import ServiceRegistry, get_registry + +AUTH = {"Authorization": "Bearer test-token"} +PROJECT = "prod" +POLICY_ID = "p-1" +RULES = [{"principal": "a@x.com", "visible_columns": ["id"]}] + + +def _app(tmp_path: Path, cls_svc: MagicMock) -> Any: + app = create_app(config_dir=str(tmp_path), auth_token="test-token") + registry = ServiceRegistry.__new__(ServiceRegistry) + registry.cls = cls_svc + app.dependency_overrides[get_registry] = lambda: registry + return app + + +def _persist_policy(config_dir: Path, policy: PermissionPolicy) -> None: + store = ConfigStore(config_dir=config_dir) + config = store.load() + config.permissions = policy + store.save(config) + + +def test_list_and_detail_call_service(tmp_path: Path) -> None: + svc = MagicMock() + svc.list_policies.return_value = {"project": PROJECT, "policies": []} + svc.get_policy.return_value = {"id": POLICY_ID} + + with TestClient(_app(tmp_path, svc)) as client: + assert client.get(f"/cls/{PROJECT}", headers=AUTH).status_code == 200 + assert client.get(f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH).status_code == 200 + + svc.list_policies.assert_called_once_with(PROJECT) + svc.get_policy.assert_called_once_with(PROJECT, POLICY_ID) + + +def test_schema_success_and_not_shadowed_by_policy_id_route(tmp_path: Path) -> None: + svc = MagicMock() + svc.fetch_schema.return_value = MagicMock(schema={"type": "object"}, reason=None) + + with TestClient(_app(tmp_path, svc)) as client: + res = client.get(f"/cls/{PROJECT}/schema", headers=AUTH) + + assert res.status_code == 200, res.text + assert res.json()["schema"] == {"type": "object"} + svc.fetch_schema.assert_called_once_with(PROJECT) + svc.get_policy.assert_not_called() + + +def test_schema_fetch_failure_is_404(tmp_path: Path) -> None: + svc = MagicMock() + svc.fetch_schema.return_value = MagicMock(schema=None, reason="not registered") + + with TestClient(_app(tmp_path, svc)) as client: + res = client.get(f"/cls/{PROJECT}/schema", headers=AUTH) + + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND" + assert "cls-policy" in res.json()["error"]["message"] + + +def test_create_and_update_pass_kwargs(tmp_path: Path) -> None: + svc = MagicMock() + svc.create_policy.return_value = {"id": POLICY_ID} + svc.update_policy.return_value = {"id": POLICY_ID} + body = { + "table_id": "in.c-crm.invoices", + "dialect": "snowflake", + "rules": RULES, + "target_projects": [999], + } + + with TestClient(_app(tmp_path, svc)) as client: + assert client.post(f"/cls/{PROJECT}", headers=AUTH, json=body).status_code == 200 + res = client.patch(f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH, json={"rules": RULES}) + assert res.status_code == 200, res.text + + create_kwargs = svc.create_policy.call_args.kwargs + assert create_kwargs["rules"] == RULES + assert create_kwargs["target_projects"] == [999] + assert create_kwargs["dry_run"] is False + args, update_kwargs = svc.update_policy.call_args + assert args == (PROJECT, POLICY_ID) + assert update_kwargs["rules"] == RULES + assert update_kwargs["table"] is None + + +def test_delete_calls_service(tmp_path: Path) -> None: + svc = MagicMock() + svc.delete_policy.return_value = {"deleted": True} + + with TestClient(_app(tmp_path, svc)) as client: + res = client.delete(f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH) + + assert res.status_code == 200, res.text + svc.delete_policy.assert_called_once_with(PROJECT, POLICY_ID, dry_run=False) + + +def test_service_error_maps_to_http_status(tmp_path: Path) -> None: + svc = MagicMock() + svc.get_policy.side_effect = KeboolaApiError( + message="not found", status_code=404, error_code=ErrorCode.NOT_FOUND + ) + + with TestClient(_app(tmp_path, svc)) as client: + res = client.get(f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH) + + assert res.status_code == 404, res.text + + +class TestPermissionGating: + @pytest.mark.parametrize( + ("deny", "blocked"), + [ + ("cli:admin", {"create", "update"}), + ("cli:destructive", {"delete"}), + ("cli:write", {"create", "update", "delete"}), # spans write+destructive+admin + ], + ) + def test_each_write_is_gated_by_its_class( + self, tmp_path: Path, deny: str, blocked: set[str] + ) -> None: + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=[deny])) + svc = MagicMock() + for method in ("create_policy", "update_policy", "delete_policy"): + getattr(svc, method).return_value = {"id": POLICY_ID} + body = {"table_id": "t.x", "rules": RULES} + + with TestClient(_app(tmp_path, svc)) as client: + statuses = { + "create": client.post(f"/cls/{PROJECT}", headers=AUTH, json=body).status_code, + "update": client.patch( + f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH, json={"table_id": "t.y"} + ).status_code, + "delete": client.delete(f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH).status_code, + } + + assert statuses == {op: 403 if op in blocked else 200 for op in statuses} + + def test_denying_cli_admin_still_allows_reads(self, tmp_path: Path) -> None: + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=["cli:admin"])) + svc = MagicMock() + svc.list_policies.return_value = {"project": PROJECT, "policies": []} + svc.get_policy.return_value = {"id": POLICY_ID} + svc.fetch_schema.return_value = MagicMock(schema={"type": "object"}, reason=None) + + with TestClient(_app(tmp_path, svc)) as client: + statuses = [ + client.get(f"/cls/{PROJECT}", headers=AUTH).status_code, + client.get(f"/cls/{PROJECT}/{POLICY_ID}", headers=AUTH).status_code, + client.get(f"/cls/{PROJECT}/schema", headers=AUTH).status_code, + ] + + assert statuses == [200, 200, 200] diff --git a/tests/test_server_rls.py b/tests/test_server_rls.py new file mode 100644 index 00000000..6c697bd0 --- /dev/null +++ b/tests/test_server_rls.py @@ -0,0 +1,314 @@ +"""Tests for `server/routers/rls.py` -- router -> service call parity + permission gating. + +Two families, mirroring ``test_server_router_calls.py`` (kwarg parity via a +mocked ``ServiceRegistry``) and ``test_server_permissions.py`` (persisted +policy -> 403). RLS ``create``/``update`` are ``admin``, ``delete`` and a +create at ``organization`` scope are ``destructive``; ``list``/``detail``/ +``schema`` stay reachable under either denial. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +if importlib.util.find_spec("fastapi") is None: # pragma: no cover + pytest.skip( + "FastAPI not installed; run `uv pip install -e '.[server]'`", allow_module_level=True + ) + +from fastapi.testclient import TestClient + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ErrorCode, KeboolaApiError +from keboola_agent_cli.models import PermissionPolicy +from keboola_agent_cli.server import create_app +from keboola_agent_cli.server.dependencies import ServiceRegistry, get_registry + +AUTH = {"Authorization": "Bearer test-token"} +PROJECT = "prod" +POLICY_ID = "p-1" + + +def _make_app_with_registry(tmp_path: Path, registry: ServiceRegistry) -> Any: + app = create_app(config_dir=str(tmp_path), auth_token="test-token") + app.dependency_overrides[get_registry] = lambda: registry + return app + + +def _mock_registry(**services: Any) -> ServiceRegistry: + registry = ServiceRegistry.__new__(ServiceRegistry) + for name, mock in services.items(): + setattr(registry, name, mock) + return registry + + +def _persist_policy(config_dir: Path, policy: PermissionPolicy) -> None: + store = ConfigStore(config_dir=config_dir) + config = store.load() + config.permissions = policy + store.save(config) + + +# --------------------------------------------------------------------------- +# Router -> service call parity +# --------------------------------------------------------------------------- + + +def test_list_policies_calls_service(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.list_policies.return_value = {"project": PROJECT, "policies": []} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.get(f"/rls/{PROJECT}", headers=AUTH) + + assert res.status_code == 200, res.text + rls_svc.list_policies.assert_called_once_with(PROJECT) + + +def test_get_policy_calls_service(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.get_policy.return_value = {"id": POLICY_ID} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.get(f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH) + + assert res.status_code == 200, res.text + rls_svc.get_policy.assert_called_once_with(PROJECT, POLICY_ID) + + +def test_schema_success(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.fetch_schema.return_value = MagicMock(schema={"type": "object"}, reason=None) + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.get(f"/rls/{PROJECT}/schema", headers=AUTH) + + assert res.status_code == 200, res.text + assert res.json()["schema"] == {"type": "object"} + + +def test_schema_fetch_failure_is_404_not_a_crash(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.fetch_schema.return_value = MagicMock(schema=None, reason="not registered yet") + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.get(f"/rls/{PROJECT}/schema", headers=AUTH) + + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND" + + +def test_schema_route_not_shadowed_by_policy_id_route(tmp_path: Path) -> None: + """`/rls/{project}/schema` must resolve to the schema route, never `detail` + + with policy_id='schema' -- the registration-order guard the router's + docstring promises.""" + rls_svc = MagicMock() + rls_svc.fetch_schema.return_value = MagicMock(schema={"type": "object"}, reason=None) + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + client.get(f"/rls/{PROJECT}/schema", headers=AUTH) + + rls_svc.fetch_schema.assert_called_once_with(PROJECT) + rls_svc.get_policy.assert_not_called() + + +def test_create_policy_passes_kwargs(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.create_policy.return_value = {"id": POLICY_ID} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + body = { + "table_id": "in.c-crm.invoices", + "dialect": "snowflake", + "rules": [{"principal": "a@x.com", "condition": {"true": True}}], + "target_projects": [999, "analytics"], + "default": {"false": True}, + "dry_run": False, + } + + with TestClient(app) as client: + res = client.post(f"/rls/{PROJECT}", headers=AUTH, json=body) + + assert res.status_code == 200, res.text + kwargs = rls_svc.create_policy.call_args.kwargs + assert kwargs["table"] == "in.c-crm.invoices" + assert kwargs["dialect"] == "snowflake" + assert kwargs["rules"] == body["rules"] + assert kwargs["scope"] == "targeted" # the default + assert kwargs["target_projects"] == [999, "analytics"] + assert kwargs["default"] == {"false": True} + assert kwargs["dry_run"] is False + + +def test_update_policy_passes_kwargs(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.update_policy.return_value = {"id": POLICY_ID} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.patch( + f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH, json={"table_id": "new.table"} + ) + + assert res.status_code == 200, res.text + args, kwargs = rls_svc.update_policy.call_args + assert args == (PROJECT, POLICY_ID) + assert kwargs["table"] == "new.table" + assert kwargs["rules"] is None + assert kwargs["default"] is None # omitted = unchanged + assert kwargs["target_projects"] is None + + +def test_update_is_patch_not_put(tmp_path: Path) -> None: + app = _make_app_with_registry(tmp_path, _mock_registry(rls=MagicMock())) + + with TestClient(app) as client: + res = client.put(f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH, json={"table_id": "x"}) + + assert res.status_code == 405, res.text + + +def test_target_projects_with_organization_scope_is_400(tmp_path: Path) -> None: + """The service refuses the combination (INVALID_ARGUMENT); REST answers it as a 400.""" + rls_svc = MagicMock() + rls_svc.create_policy.side_effect = KeboolaApiError( + message="target projects require scope 'targeted'", + status_code=400, + error_code=ErrorCode.INVALID_ARGUMENT, + ) + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + body = { + "table_id": "t.x", + "rules": [{"principal": "a@x.com", "condition": {"true": True}}], + "scope": "organization", + "target_projects": [7], + } + + with TestClient(app) as client: + res = client.post(f"/rls/{PROJECT}", headers=AUTH, json=body) + + assert res.status_code == 400, res.text + + +def test_delete_policy_calls_service(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.delete_policy.return_value = {"deleted": True} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.delete(f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH) + dry = client.delete(f"/rls/{PROJECT}/{POLICY_ID}?dry_run=true", headers=AUTH) + + assert res.status_code == 200, res.text + assert dry.status_code == 200, dry.text + assert [c.kwargs["dry_run"] for c in rls_svc.delete_policy.call_args_list] == [False, True] + assert rls_svc.delete_policy.call_args.args == (PROJECT, POLICY_ID) + + +def test_keboola_api_error_from_service_maps_to_http_status(tmp_path: Path) -> None: + rls_svc = MagicMock() + rls_svc.get_policy.side_effect = KeboolaApiError( + message="not found", status_code=404, error_code=ErrorCode.NOT_FOUND + ) + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.get(f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH) + + assert res.status_code == 404, res.text + + +# --------------------------------------------------------------------------- +# Permission gating -- create/update `admin`; delete and organization scope `destructive` +# --------------------------------------------------------------------------- + + +class TestPermissionGating: + def test_denying_cli_admin_blocks_create(self, tmp_path: Path) -> None: + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=["cli:admin"])) + rls_svc = MagicMock() + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.post( + f"/rls/{PROJECT}", + headers=AUTH, + json={ + "table_id": "t", + "dialect": "snowflake", + "rules": [{"principal": "a@x.com", "condition": {"true": True}}], + }, + ) + + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "PERMISSION_DENIED" + rls_svc.create_policy.assert_not_called() + + @pytest.mark.parametrize(("deny", "status"), [("cli:destructive", 403), ("cli:admin", 200)]) + def test_delete_is_destructive_not_admin(self, tmp_path: Path, deny: str, status: int) -> None: + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=[deny])) + rls_svc = MagicMock() + rls_svc.delete_policy.return_value = {"deleted": True} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.delete(f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH) + + assert res.status_code == status, res.text + assert rls_svc.delete_policy.called is (status == 200) + + @pytest.mark.parametrize(("scope", "status"), [("targeted", 200), ("organization", 403)]) + def test_denying_cli_destructive_blocks_only_organization_scope( + self, tmp_path: Path, scope: str, status: int + ) -> None: + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=["cli:destructive"])) + rls_svc = MagicMock() + rls_svc.create_policy.return_value = {"id": POLICY_ID} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + body = { + "table_id": "t.x", + "rules": [{"principal": "a@x.com", "condition": {"true": True}}], + "scope": scope, + } + + with TestClient(app) as client: + res = client.post(f"/rls/{PROJECT}", headers=AUTH, json=body) + + assert res.status_code == status, res.text + assert rls_svc.create_policy.called is (status == 200) + + def test_denying_cli_admin_still_allows_reads(self, tmp_path: Path) -> None: + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=["cli:admin"])) + rls_svc = MagicMock() + rls_svc.list_policies.return_value = {"project": PROJECT, "policies": []} + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.get(f"/rls/{PROJECT}", headers=AUTH) + + assert res.status_code == 200, res.text + rls_svc.list_policies.assert_called_once() + + def test_denying_cli_write_also_blocks_admin_ops(self, tmp_path: Path) -> None: + """`cli:write` spans write+destructive+admin (see permissions.py) -- + + pins that RLS writes are not a hole in that broader firewall.""" + _persist_policy(tmp_path, PermissionPolicy(mode="allow", deny=["cli:write"])) + rls_svc = MagicMock() + app = _make_app_with_registry(tmp_path, _mock_registry(rls=rls_svc)) + + with TestClient(app) as client: + res = client.delete(f"/rls/{PROJECT}/{POLICY_ID}", headers=AUTH) + + assert res.status_code == 403, res.text + rls_svc.delete_policy.assert_not_called()