Skip to content
Open
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2026,6 +2026,54 @@ mb glossary delete 3 --yes
| ------- | ------------------ |
| `--yes` | Skip confirmation. |

## Groups

Permission groups (user groups), from `/api/permissions/group`. Every verb needs an admin. A group's members come from Metabase's people admin or an identity provider (SSO group mappings, SCIM); these commands manage the groups themselves. The built-in All Users and Administrators groups cannot be renamed or deleted.

### `mb group list`

Lists every group with its `id`, `name` and `member_count`. A group's `id` is the key it is listed under in the data permissions graph.

```sh
mb group list
mb group list --json
```

### `mb group create`

Create a group from `--name`, or from a JSON body that holds exactly a `name` key. The flag cannot be combined with `--body` or `--file`. A blank name is refused. The new group has no members and no permissions.

```sh
mb group create --name "Analysts"
mb group create --body '{"name":"Analysts"}'
```

| Flag | Description |
| --------------- | ----------------------- |
| `--name <text>` | The group name. |
| `--body <json>` | Inline JSON body. |
| `--file <path>` | Path to JSON body file. |

### `mb group update <id>`

Rename a group. Takes the same flags and body as `create`.

```sh
mb group update 3 --name "Data analysts"
```

### `mb group delete <id>`

Delete a group with its memberships and permissions. Its members keep their other groups. Prompts for confirmation on a TTY; requires `--yes` otherwise.

```sh
mb group delete 3 --yes
```

| Flag | Description |
| ------- | ------------------ |
| `--yes` | Skip confirmation. |

## Settings

Read and write Metabase instance settings via `/api/setting`. Listing all settings requires admin privileges; per-key reads/writes additionally enforce per-setting access. Setting values are always JSON — `"main"` is the string `main`, `42` is a number, `null` deletes the override and resets the value to its default.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/skill-data/core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Top-level command groups (run `mb <group> --help` to discover verbs):

```
auth | db | table | field | upload | content-translation | query | card | dashboard | snippet | segment | measure | collection | library
document | glossary | timeline | timeline-event | transform | transform-job | transform-tag | transform-test | alert | subscription | setting
document | glossary | group | timeline | timeline-event | transform | transform-job | transform-tag | transform-test | alert | subscription | setting
search | dependency | git-sync | data-action | setup | eid | uuid | upgrade | skills
```

Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/commands/user-group/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Group, GroupCreateInput } from "@metabase/client/domain/group";
import { renderSummary } from "../../output/render";
import { groupView } from "../../output/views/group";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";

import { nameFlags, readGroupInput } from "./name-flags";

export default defineMetabaseCommand({
meta: { name: "create", description: "Create a permission group" },
details:
"Pass `--name`, or a JSON body with exactly a `name` key. A body piped on stdin is not read while the flag is given. The name may not be blank. The group starts with no members and no permissions. Admins only.",
requires: ["group.create"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...nameFlags,
},
inputSchema: GroupCreateInput,
outputSchema: Group,
examples: ['mb group create --name "Analysts"', 'mb group create --body \'{"name":"Analysts"}\''],
async run({ args, ctx, getClient }) {
const body = await readGroupInput(args);
const client = await getClient();
const created = await client.group.create(body);
renderSummary(created, groupView, `Created group ${created.id} "${created.name}".`, ctx);
},
});
33 changes: 33 additions & 0 deletions packages/cli/src/commands/user-group/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { confirmAndDelete, DeleteResult } from "../delete-runtime";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "delete", description: "Delete a permission group by id" },
details:
"Deletes the group with its memberships and permissions; its members keep their other groups. The built-in All Users and Administrators groups cannot be deleted. Admins only.",
requires: ["group.delete"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
yes: { type: "boolean", description: "Skip confirmation", default: false },
id: { type: "positional", description: "Group id", required: true },
},
outputSchema: DeleteResult,
examples: ["mb group delete 3 --yes", "mb group delete 3"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
await confirmAndDelete({
id,
yes: args.yes,
promptMessage: `Delete group ${id}?`,
successMessage: `Deleted group ${id}.`,
abortMessage: `Aborted; group ${id} was not deleted.`,
deleteResource: () => client.group.delete(id),
ctx,
});
},
});
13 changes: 13 additions & 0 deletions packages/cli/src/commands/user-group/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineCommandGroup } from "../group";

// The folder is `user-group` because `commands/group.ts` is the command-group helper.
export default defineCommandGroup({
name: "group",
description: "Manage permission groups (user groups)",
subCommands: {
list: () => import("./list").then((mod) => mod.default),
create: () => import("./create").then((mod) => mod.default),
update: () => import("./update").then((mod) => mod.default),
delete: () => import("./delete").then((mod) => mod.default),
},
});
29 changes: 29 additions & 0 deletions packages/cli/src/commands/user-group/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { GroupCompact } from "@metabase/client/domain/group";
import { renderList } from "../../output/render";
import { listEnvelopeSchema } from "../../output/types";
import { groupView } from "../../output/views/group";
import { windowList } from "../../output/window";
import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";

export const GroupListEnvelope = listEnvelopeSchema(GroupCompact);

export default defineMetabaseCommand({
meta: { name: "list", description: "List permission groups" },
details:
"Lists every group, including the built-in All Users and Administrators, with its member count. Admins only. A group's id is the key it is listed under in the data permissions graph.",
requires: ["group.list"],
args: {
...outputFlags,
...listFlags,
...profileFlag,
...connectionFlags,
},
outputSchema: GroupListEnvelope,
examples: ["mb group list", "mb group list --json"],
async run({ ctx, getClient }) {
const client = await getClient();
const { data } = await client.group.list();
renderList(windowList(data, ctx.range), groupView, ctx);
},
});
34 changes: 34 additions & 0 deletions packages/cli/src/commands/user-group/name-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { FlagValues } from "../flag-values";

import { GroupCreateInput } from "@metabase/client/domain/group";
import { ConfigError } from "@metabase/client/errors";

import { readBody } from "../../runtime/body";
import { givenValue } from "../../runtime/input";
import { bodyInputFlags } from "../body-flags";

export const nameFlags = {
...bodyInputFlags,
name: { type: "string", description: "The group name" },
} as const;

/**
* `--name` and a body are alternatives, so a `--body` or `--file` beside the flag is refused rather
* than dropped. A body piped on stdin is not read while the flag is given.
*/
export async function readGroupInput(
args: FlagValues<typeof nameFlags>,
): Promise<GroupCreateInput> {
if (args.name === undefined) {
return readBody({ flag: args.body, file: args.file }, GroupCreateInput);
}
if (givenValue(args.body) !== null || givenValue(args.file) !== null) {
throw new ConfigError("--name cannot be combined with --body or --file");
}
// The flag runs through the body's own schema, so both paths refuse the same values.
const parsed = GroupCreateInput.shape.name.safeParse(args.name);
if (!parsed.success) {
throw new ConfigError(`--name ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
}
return { name: parsed.data };
}
32 changes: 32 additions & 0 deletions packages/cli/src/commands/user-group/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Group, GroupUpdateInput } from "@metabase/client/domain/group";
import { renderSummary } from "../../output/render";
import { groupView } from "../../output/views/group";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

import { nameFlags, readGroupInput } from "./name-flags";

export default defineMetabaseCommand({
meta: { name: "update", description: "Rename a permission group" },
details:
"Pass `--name`, or a JSON body with exactly a `name` key. The built-in All Users and Administrators groups cannot be renamed. Admins only.",
requires: ["group.update"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...nameFlags,
id: { type: "positional", description: "Group id", required: true },
},
inputSchema: GroupUpdateInput,
outputSchema: Group,
examples: ['mb group update 3 --name "Data analysts"', "mb group update 3 --file group.json"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const body = await readGroupInput(args);
const client = await getClient();
const updated = await client.group.update(id, body);
renderSummary(updated, groupView, `Renamed group ${updated.id} to "${updated.name}".`, ctx);
},
});
1 change: 1 addition & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const main: CommandDef = defineCommand({
library: () => import("./commands/library").then((mod) => mod.default),
document: () => import("./commands/document").then((mod) => mod.default),
glossary: () => import("./commands/glossary").then((mod) => mod.default),
group: () => import("./commands/user-group").then((mod) => mod.default),
transform: () => import("./commands/transform").then((mod) => mod.default),
"transform-job": () => import("./commands/transform-job").then((mod) => mod.default),
"transform-tag": () => import("./commands/transform-tag").then((mod) => mod.default),
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/output/views/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type Group, GroupCompact } from "@metabase/client/domain/group";

import type { ResourceView } from "../view";

export const groupView: ResourceView<Group> = {
compactPick: GroupCompact,
tableColumns: [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "member_count", label: "Members" },
],
};
6 changes: 6 additions & 0 deletions packages/cli/src/runtime/command-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@ const ALL_COMMANDS = [
"glossary create",
"glossary update",
"glossary delete",
"group list",
"group create",
"group update",
"group delete",
"transform list",
"transform get",
"transform dependencies",
Expand Down Expand Up @@ -507,6 +511,8 @@ const BODY_COMMANDS = [
"document update",
"glossary create",
"glossary update",
"group create",
"group update",
"transform create",
"transform update",
"transform-job create",
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { erdResource } from "./resources/erd";
import { fieldResource } from "./resources/field";
import { gitSyncResource } from "./resources/git-sync";
import { glossaryResource } from "./resources/glossary";
import { groupResource } from "./resources/group";
import { libraryResource } from "./resources/library";
import { measureResource } from "./resources/measure";
import { metricResource } from "./resources/metric";
Expand Down Expand Up @@ -60,6 +61,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions)
field: fieldResource(transport),
gitSync: gitSyncResource(transport),
glossary: glossaryResource(transport),
group: groupResource(transport),
library: libraryResource(transport),
measure: measureResource(transport),
metric: metricResource(transport),
Expand Down
24 changes: 24 additions & 0 deletions packages/client/src/domain/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from "zod";

import { NonBlankText } from "./text";

// A permission group: the id a permissions graph is keyed by, and its name. `member_count`, the
// number of users in it, rides the listing only; a created or renamed group answers without it.
export const Group = z
.object({
id: z.number().int(),
name: z.string(),
member_count: z.number().int().optional(),
})
.loose();
export type Group = z.infer<typeof Group>;

export const GroupCompact = Group.pick({ id: true, name: true, member_count: true }).strip();
export type GroupCompact = z.infer<typeof GroupCompact>;

// The server stores only the name, so refuse any other key rather than let it vanish.
export const GroupCreateInput = z.object({ name: NonBlankText }).strict();
export type GroupCreateInput = z.infer<typeof GroupCreateInput>;

export const GroupUpdateInput = GroupCreateInput;
export type GroupUpdateInput = GroupCreateInput;
Loading