Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ called out explicitly under **Changed** or **Removed**.
`alignment`), and a centered `Image` (`position: "center"`, the only
value this surface sets) now actually renders centered in `content`
instead of flush left.
- **`kizen permissions group-update <group> --settings-file <f>`** raises or
lowers object/field/section controls on an *existing* permission group —
the same op shapes `group-create --settings-file` already accepts, now a
second consumer. Dry-run shows a `change` (current -> target) per op, read
from the live group. Object/field ops that target an object the group has
no entry for **add it at `none` and cannot raise it** (confirmed live: the
server silently corrects the requested level and reports it in the
response). Both `group-update` and `group-create --settings-file` catch
this in two ways: an `object`-op `level` outside the control's own
`allowed_access` (e.g. `associated_records: none`, which the server would
silently clamp to `view`) is rejected up front with a `PlanError`; and a
mismatch that survives that check because a *legal* value still got
adjusted by a cross-field rule (e.g. `associated_records >= all_records`)
is reported as an `adjusted` op with a plain-language message — not a
failure, since the server applied a value the design already delegates to
it — while a mismatch on a control that had **no entry at all** at plan
time (the fresh-insert case above) still surfaces as `failed`. See
`docs/specs/permission-group.md`.

### Fixed

Expand Down
27 changes: 27 additions & 0 deletions scripts/cli-tree-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2655,6 +2655,7 @@
│ group Show one permission group as a sectioned permission map (mirrors the UI). │
│ meta Show the permissions catalog / meta-data (raw). │
│ group-create Create a permission group (full default structure, optionally shaped). │
│ group-update Raise/lower specific controls on an existing permission group. │
│ group-delete Delete a permission group. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Expand Down Expand Up @@ -2712,6 +2713,29 @@
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Usage: permissions group-update [OPTIONS] {group}

Raise/lower specific controls on an existing permission group.

Each op calls `object-update` (object/field) or a section PATCH — never
a full-group PUT — so the server normalizes cross-field rules for you.

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮
│ * group <str> Permission group name or UUID. [required] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
│ * --settings-file <str> JSON list of shaping ops to apply (object/field/section) — │
│ same shape as `group-create --settings-file`. │
│ [required] │
│ --dry-run Show the plan without applying. │
│ --yes -y Skip the y/N confirmation prompt. │
│ --json Emit JSON (plan with --dry-run, results otherwise). │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Settings-file shape (a list of shaping ops): see `kizen docs show permission-group`


Usage: permissions groups [OPTIONS]

List permission groups (raw).
Expand Down Expand Up @@ -4544,6 +4568,9 @@
===== permissions group-delete =====


===== permissions group-update =====


===== permissions groups =====


Expand Down
7 changes: 0 additions & 7 deletions src/kizen_builder/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,6 @@ def delete_permission_group(client: KizenClient, group_id: str) -> None:
client.delete(f"/api/permission-group/{group_id}")


def duplicate_permission_group(
client: KizenClient, group_id: str, payload: dict[str, Any]
) -> dict[str, Any]:
"""POST /api/permission-group/{id}/duplicate — {name}. Copies all levels."""
return client.post(f"/api/permission-group/{group_id}/duplicate", json=payload)


def patch_permission_group(
client: KizenClient, group_id: str, payload: dict[str, Any]
) -> dict[str, Any]:
Expand Down
4 changes: 3 additions & 1 deletion src/kizen_builder/cli/_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def _render_result(result: plan_tools.ApplyResult) -> None:
table.add_column("server uuid")
table.add_column("note")
for r in result.results:
symbol = {"ok": "✓", "skipped": "·", "failed": "✗"}.get(r.status, "?")
symbol = {"ok": "✓", "skipped": "·", "failed": "✗", "adjusted": "~"}.get(
r.status, "?"
)
table.add_row(
symbol,
r.kind,
Expand Down
37 changes: 37 additions & 0 deletions src/kizen_builder/cli/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,43 @@ def perms_group_create(
)


@perms_app.command(
"group-update",
epilog="Settings-file shape (a list of shaping ops): see `kizen docs show permission-group`",
)
def perms_group_update(
group: str = typer.Argument(..., help="Permission group name or UUID."),
settings_file: str = typer.Option(
...,
"--settings-file",
help="JSON list of shaping ops to apply (object/field/section) — same "
"shape as `group-create --settings-file`.",
),
dry_run: bool = typer.Option(
False, "--dry-run", help="Show the plan without applying."
),
yes: bool = typer.Option(
False, "--yes", "-y", help="Skip the y/N confirmation prompt."
),
json_out: bool = typer.Option(
False, "--json", help="Emit JSON (plan with --dry-run, results otherwise)."
),
) -> None:
"""Raise/lower specific controls on an existing permission group.

Each op calls `object-update` (object/field) or a section PATCH — never
a full-group PUT — so the server normalizes cross-field rules for you.
"""
group_id = _resolve_group_id(group)
settings = json.loads(Path(settings_file).read_text())
_run_mutation(
lambda: perm_planners.plan_update_permission_group(group_id, settings),
dry_run=dry_run,
yes=yes,
json_out=json_out,
)


@perms_app.command("group-delete")
def perms_group_delete(
group: str = typer.Argument(..., help="Permission group name or UUID."),
Expand Down
1 change: 1 addition & 0 deletions src/kizen_builder/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ kizen roles create --name X [--group <name|uuid> ...] [--permission <flag> ...]
kizen roles update <name|uuid> [--name Y] [--group <name|uuid> ...] [--default/--no-default] # --group REPLACES the set
kizen roles delete <name|uuid>
kizen permissions group-create --name X [--base default|clone] [--from <name|uuid>] [--settings-file f]
kizen permissions group-update <name|uuid> --settings-file f # raise/lower controls on an EXISTING group; same op shapes as group-create
kizen permissions group-delete <name|uuid>

# patch one automation step (GET → translate → mutate node → validate → atomic PUT)
Expand Down
92 changes: 83 additions & 9 deletions src/kizen_builder/docs/specs/permission-group.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Spec shape: permission-group shaping ops

**Consumed by:** `kizen permissions group-create --settings-file <f>`.
**Consumed by:** `kizen permissions group-create --settings-file <f>` and
`kizen permissions group-update <group> --settings-file <f>` — same op list,
same file. `group-create` applies it after building a new group;
`group-update` applies it to a group that already exists, and its dry-run
preview shows a `change` (current level -> target level) per op read from
the live group, not just the target.

A group is created at a **base** (`--base default` = fresh group at Kizen's
default levels, or `--base clone --from <group>` = copy an existing group).
Expand All @@ -17,12 +22,17 @@ The `--settings-file` is an optional **JSON list of shaping ops** applied

```json
[
{ "type": "object", "object_id": "<object_uuid>", "key": "records", "level": "edit" },
{ "type": "object", "object_id": "<object_uuid>", "key": "all_records", "level": "edit" },
{ "type": "field", "object_id": "<object_uuid>", "field_id": "<field_uuid>", "level": "view" },
{ "type": "section", "section_key": "automations", "value": true }
]
```

`key` must be a real object control key — `all_records`, `associated_records`,
`create_record`, `unarchive_all`, `record_overview_chart_view`,
`default_for_new_field`, and others (`records` is not one; see
`kizen permissions meta` for the full list). *(confirmed live 2026-09-01)*

```bash
kizen permissions group-create --name "Sales Ops" --settings-file ops.json --dry-run
```
Expand All @@ -31,13 +41,18 @@ kizen permissions group-create --name "Sales Ops" --settings-file ops.json --dry

| `type` | Keys | Effect |
|--------|------|--------|
| `object` | `object_id`, `key`, `level` | Set an object-level permission (e.g. `records`, `custom_fields`) to `level`. |
| `object` | `object_id`, `key`, `level` | Set an object-level permission (e.g. `all_records`, `create_record`) to `level`. |
| `field` | `object_id`, `field_id`, `level` | Set a per-field control to `level`. |
| `section` | `section_key`, `value` | Toggle/set an app-section permission. |

`level` is a level **name** (`none`, `view`, `edit`, `remove`, …) or its integer
index — the valid range per control comes from that control's `allowed_access`
(visible in `permissions group <name>`).
(visible in `permissions group <name>`). Both `group-create --settings-file`
and `group-update` reject an out-of-range `level` for an `object` op at plan
time (e.g. `associated_records: none`, which has no `none` in its
`allowed_access`) with a `PlanError` naming the control and its valid
levels, instead of sending it and letting the server silently clamp it — see
"Write model" below. *(confirmed live 2026-09-01)*

## Gotchas

Expand Down Expand Up @@ -86,7 +101,45 @@ and only resets leaf values, which is why `group-create` needs a `--base`.
(`"customize_homepages: This field is required."`).
- **Custom object + field perms** go through a different endpoint:
`PATCH /api/permission-group/{id}/object-update` with
`{custom_object: {id}, field?: {id}, key?, permission_level: 0-3}`.
`{custom_object: {id}, field?: {id}, key?, permission_level: 0-3}`. Three
distinct outcomes when the response's `permission_level` differs from what
was requested, all reported via `response.details.message`:
1. **Out-of-range clamp.** Requesting `associated_records: none` when
`allowed_access` is `["view","edit","remove"]` comes back
`permission_level: 1` (`view`). `group-create --settings-file` and
`group-update` never let this reach the endpoint at all (see "Op
shapes" above) — rejected as a `PlanError` at plan time instead.
2. **Cross-field normalization on a control the group already carries.**
A *request the control's own `allowed_access` says is legal* still gets
adjusted to satisfy a rule involving another control's value — e.g. on
a group where `all_records` had just been raised to `remove`,
requesting `associated_records: view` (legal on its own —
`allowed_access` includes `view`) came back `permission_level: 3`
(`remove`), to satisfy `associated_records >= all_records`. The write
still succeeded — the server picked the nearest legal value for the
*combined* state, which is exactly the normalization these commands
delegate to `object-update` for, rather than hand-building a full
group PUT and enforcing the rules here. Neither command can predict
this at plan time without reimplementing the server's rule engine, so
it's only visible in the apply result: reported as `status: "adjusted"`
(not `"failed"`), with a message like `"requested view, server
normalized to remove (Permission level was automatically corrected by
rule.)"` — `kizen apply`'s exit code stays 0.
3. **Insert of an object with no entry in the group at all.** Always
lands at `none`, **even when the requested level is in range** — the
defect this item exists to at least surface honestly. Not a one-time
insert quirk: a second identical apply against the now-present entry
is *also* corrected back to `none` — the exact server rule wasn't
identified, only that it isn't simply "no entry yet" (case 2's example
above shows a *present* control also being normalized). This is the
one genuine failure: reported as `status: "failed"`, and `kizen
apply`'s exit code goes non-zero.

`group-create --settings-file`/`group-update` distinguish case 2 from
case 3 using whether the control had a live entry at plan time (recorded
on the op at plan time, since the group's state can change between ops in
the same plan — case 2's example needed `all_records` raised by an
earlier op in the same batch to reproduce). *(confirmed live 2026-09-01)*
- **A full PUT** `/api/permission-group/{id}` replaces the whole structure, but
is subject to cross-field **rules** — e.g. `associated_records ≥ all_records`,
`unarchive_all ≤ unarchive_associated`, `create_record` needs
Expand All @@ -102,10 +155,31 @@ and only resets leaf values, which is why `group-create` needs a `--base`.
## Command surface

`kizen roles list|get|create|update|delete` and `kizen permissions
groups|group|meta|group-create|group-delete`. **Names are accepted anywhere a
role or group is referenced** — resolved to a UUID, with an available-list on a
miss. `kizen permissions group <name> [--fields]` renders the sectioned slider
view that mirrors the permission editor.
groups|group|meta|group-create|group-update|group-delete`. **Names are
accepted anywhere a role or group is referenced** — resolved to a UUID, with
an available-list on a miss. `kizen permissions group <name> [--fields]`
renders the sectioned slider view that mirrors the permission editor.

`group-update` applies shaping ops directly — `object`/`field` ops call
`object-update`, `section` ops call the section PATCH — it never assembles a
full-group PUT, so the server's cross-field-rule normalization on those two
endpoints still applies (see "Write model" above, case 2) — deliberately:
reimplementing that rule engine client-side isn't in scope. Two consequences
in the CLI:
- A `change` preview line for an `object`/`field` op reads e.g. `"Records:
view -> edit (subject to server rules)"` when the control already has a
live entry — the target level is what was asked for, not a guarantee, since
a later op in the same plan (or the group's pre-existing state) can still
trigger a cross-field normalization that changes the outcome.
- What happens when an `object`/`field` op targets an object the group has
no entry for: **it adds the object at `none` and cannot raise it** (not
"does not yet add missing objects") — `group-update` detects this specific
case (a request that was in range but landed at `none` anyway, on a
control absent at plan time) and reports it as `failed`. A normalization
on a control that *was* present is reported as `adjusted` instead — a
successful write, not a failure — so it doesn't cost `kizen apply` a
non-zero exit for the server doing exactly what this design delegates to
it.

## See also

Expand Down
16 changes: 14 additions & 2 deletions src/kizen_builder/tools/permission_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def current_level(self) -> str:
if self._wire_key is None
else self._container[self._wire_key]
)
return _value_to_level(value, self.allowed_access)
return value_to_level(value, self.allowed_access)

def set_level(self, level: str) -> None:
if self._wire_key is None:
Expand All @@ -81,7 +81,7 @@ def set_level(self, level: str) -> None:
)


def _value_to_level(value: Any, allowed: list[str]) -> str:
def value_to_level(value: Any, allowed: list[str]) -> str:
"""Read a wire value back to a level label."""
if isinstance(value, bool):
if not value:
Expand All @@ -98,6 +98,18 @@ def _value_to_level(value: Any, allowed: list[str]) -> str:
return "none"


def substitute_object_label(label: str, entity: str) -> str:
"""Fill an object-area control label's ``{0}`` placeholder with the
object's display name (e.g. ``"All {0} Records"`` -> ``"All Companies
Records"``). Meta only templates object labels this way — field and
section labels never carry ``{0}`` — so a label without it is returned
unchanged.
"""
if "{0}" not in label:
return label
return label.replace("{0}", entity).replace(" ", " ").strip()


def _level_to_shape(shape: Any, level: str) -> Any:
"""Return a value matching ``shape``'s wire form set to ``level``."""
idx = level_index(level)
Expand Down
7 changes: 4 additions & 3 deletions src/kizen_builder/tools/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,10 @@ def describe_group(group_id: str, include_fields: bool = False) -> dict[str, Any
blk["enabled"] = leaf.current_level != "none"
continue
row_label = leaf.row_label
if blk["area"] == "object" and "{0}" in row_label:
entity = obj_entities.get(leaf.block_key, "")
row_label = row_label.replace("{0}", entity).replace(" ", " ").strip()
if blk["area"] == "object":
row_label = pb.substitute_object_label(
row_label, obj_entities.get(leaf.block_key, "")
)
if leaf.category == "custom_fields":
row_label = field_names.get(leaf.row_key, leaf.row_key)
category_label = (
Expand Down
Loading
Loading