Developer Tooling & API

How to Provision Metabase Users and Permissions Programmatically

Provisioning Metabase users and permissions programmatically means using the Metabase API to create users, assign them to permission groups, and confi...

šŸ“…
šŸ“–9 min read

How to Provision Metabase Users and Permissions Programmatically

Provisioning Metabase users and permissions programmatically means using the Metabase API to create users, assign them to permission groups, and configure what data each group can access — without manual intervention through the UI. This is essential for SaaS products with embedded analytics (where user accounts are created automatically as customers sign up), for organizations that manage Metabase at scale (tens or hundreds of users), and for teams that want to manage Metabase configuration as code.

---

The Metabase Permissions Model

Before writing provisioning code, understand how Metabase's permission system is structured:

Users

└── belong to one or more Groups └── Groups have permissions on ā”œā”€ā”€ Data (databases, schemas, tables) │ ā”œā”€ā”€ Unrestricted access │ ā”œā”€ā”€ Granular (per-schema or per-table) │ ā”œā”€ā”€ Sandboxed (row-level security) │ └── No access └── Collections (folders) ā”œā”€ā”€ View ā”œā”€ā”€ Curate (edit) └── No access

Every user belongs to the built-in All Users group, which defines the baseline permissions. Additional groups add or restrict access on top of that baseline.

Built-In Groups

GroupPurpose
All UsersBaseline permissions applied to everyone
AdministratorsFull access to everything including admin panel
Custom groups are created for any access pattern that differs from these defaults.

---

User Management

Creating a User

javascript

async function createUser({ firstName, lastName, email, groupIds = [] }) { const res = await fetch(${process.env.METABASE_SITE_URL}/api/user, { method: "POST", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ first_name: firstName, last_name: lastName, email, // password is required but will be overridden by SSO if configured password: generateSecurePassword(), }), });

if (!res.ok) { const error = await res.json(); // Handle "email already exists" gracefully if (res.status === 400 && error.errors?.email) { return getExistingUser(email); } throw new Error(Failed to create user: ${JSON.stringify(error)}); }

const user = await res.json();

// Add to specified groups for (const groupId of groupIds) { await addUserToGroup(user.id, groupId); }

return user; }

function generateSecurePassword() { // Generate a random password — user will authenticate via SSO // or reset password via email return require("crypto").randomBytes(32).toString("hex"); }

Finding an Existing User

javascript

async function getUserByEmail(email) { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/user?query=${encodeURIComponent(email)}, { headers: { "x-api-key": process.env.METABASE_API_KEY }, } ); const { data } = await res.json(); return data.find((u) => u.email === email) || null; }

Updating a User

javascript

async function updateUser(userId, updates) { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/user/${userId}, { method: "PUT", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify(updates), } ); return res.json(); }

// Update user attributes (used for data sandboxing) await updateUser(userId, { login_attributes: { organization_id: "99", role: "admin", }, });

Deactivating a User

Metabase soft-deletes users — deactivated users can't log in but their content is preserved:

javascript

async function deactivateUser(userId) { await fetch(${process.env.METABASE_SITE_URL}/api/user/${userId}, { method: "DELETE", headers: { "x-api-key": process.env.METABASE_API_KEY }, }); }

---

Group Management

Creating a Group

javascript

async function createGroup(name) { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/permissions/group, { method: "POST", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ name }), } ); return res.json(); }

Adding and Removing Users from Groups

javascript

async function addUserToGroup(userId, groupId) { await fetch( ${process.env.METABASE_SITE_URL}/api/permissions/group/${groupId}/membership, { method: "POST", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ user_id: userId }), } ); }

async function removeUserFromGroup(membershipId) { await fetch( ${process.env.METABASE_SITE_URL}/api/permissions/membership/${membershipId}, { method: "DELETE", headers: { "x-api-key": process.env.METABASE_API_KEY }, } ); }

async function getUserGroupMemberships(userId) { const user = await fetch( ${process.env.METABASE_SITE_URL}/api/user/${userId}, { headers: { "x-api-key": process.env.METABASE_API_KEY } } ).then((r) => r.json());

return user.group_ids || []; }

---

Configuring the Permissions Graph

The permissions graph is the single data structure that represents all data access permissions for all groups across all databases. It's retrieved and updated as a single API call.

Reading the Permissions Graph

javascript

async function getPermissionsGraph() { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/permissions/graph, { headers: { "x-api-key": process.env.METABASE_API_KEY } } ); return res.json(); }

The response structure:

json

{ "revision": 15, "groups": { "1": { // All Users group (id: 1) "3": { // Database id: 3 "schemas": "all" // Access level } }, "5": { // Custom group id: 5 "3": { "schemas": { "public": { "orders": "all", "customers": "sandboxed" } } } } } }

Updating the Permissions Graph

The permissions graph must be updated as a complete object — partial updates are not supported. Always read the current graph first, modify it, and write it back:

javascript

async function updatePermissionsGraph(graphModifier) { // Read current graph const graph = await getPermissionsGraph();

// Apply modifications const updatedGraph = graphModifier(graph);

// Write back with the current revision number const res = await fetch( ${process.env.METABASE_SITE_URL}/api/permissions/graph, { method: "PUT", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify(updatedGraph), } ); return res.json(); }

// Grant a group full access to a database async function grantDatabaseAccess(groupId, databaseId) { return updatePermissionsGraph((graph) => { if (!graph.groups[groupId]) { graph.groups[groupId] = {}; } graph.groups[groupId][databaseId] = { schemas: "all", }; return graph; }); }

// Revoke a group's access to a database async function revokeDatabaseAccess(groupId, databaseId) { return updatePermissionsGraph((graph) => { if (graph.groups[groupId]) { delete graph.groups[groupId][databaseId]; } return graph; }); }

---

Collection Permissions

Collection permissions control whether a group can view or edit dashboards and questions in a collection:

javascript

async function getCollectionPermissionsGraph() { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/collection/graph, { headers: { "x-api-key": process.env.METABASE_API_KEY } } ); return res.json(); }

async function setCollectionPermission(groupId, collectionId, permission) { const graph = await getCollectionPermissionsGraph();

if (!graph.groups[groupId]) { graph.groups[groupId] = {}; }

// Permission values: "write" (curate), "read" (view), "none" graph.groups[groupId][collectionId] = permission;

const res = await fetch( ${process.env.METABASE_SITE_URL}/api/collection/graph, { method: "PUT", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify(graph), } ); return res.json(); }

// Example: give a group view-only access to a collection await setCollectionPermission(groupId, collectionId, "read");

// Give a group edit access (can create/modify dashboards) await setCollectionPermission(groupId, collectionId, "write");

// Remove a group's access to a collection await setCollectionPermission(groupId, collectionId, "none");

---

Full Tenant Provisioning Pattern

Here's a complete pattern for provisioning a new customer tenant with proper permissions and data isolation:

javascript

async function provisionTenant({ tenantId, tenantName, adminUser, databaseId, sharedCollectionId, // collection with shared/template dashboards }) { console.log(Provisioning tenant: ${tenantName});

// 1. Create permission group for this tenant const group = await findOrCreateGroup(Tenant: ${tenantId});

// 2. Create admin user const user = await findOrCreateUser({ email: adminUser.email, firstName: adminUser.firstName, lastName: adminUser.lastName, });

// Set user attributes for data sandboxing await updateUser(user.id, { login_attributes: { organization_id: String(tenantId), }, });

// 3. Add user to tenant group const memberships = await getUserGroupMemberships(user.id); if (!memberships.includes(group.id)) { await addUserToGroup(user.id, group.id); }

// 4. Create a collection for this tenant's dashboards const collection = await findOrCreateCollection( ${tenantName} Dashboards, sharedCollectionId );

// 5. Grant the tenant group read access to their collection await setCollectionPermission(group.id, collection.id, "read");

// 6. Grant data access (sandboxed) for the shared database // Note: sandbox policy configuration is separate — done via Admin UI // or /api/permissions/graph with sandboxed table settings await grantDatabaseAccess(group.id, databaseId);

console.log(āœ“ Tenant ${tenantName} provisioned); return { groupId: group.id, userId: user.id, collectionId: collection.id, }; }

// Helper: find or create to make the function idempotent async function findOrCreateGroup(name) { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/permissions/group, { headers: { "x-api-key": process.env.METABASE_API_KEY } } ); const groups = await res.json(); const existing = groups.find((g) => g.name === name); if (existing) return existing; return createGroup(name); }

async function findOrCreateUser({ email, firstName, lastName }) { const existing = await getUserByEmail(email); if (existing) return existing; return createUser({ email, firstName, lastName }); }

---

Syncing Permissions with an External System

For large deployments, Metabase permissions should mirror your application's access control system. A sync job runs periodically (or on webhook) to reconcile:

javascript

async function syncPermissions(appUsers) { const [metabaseUsers, metabaseGroups] = await Promise.all([ getAllMetabaseUsers(), getAllMetabaseGroups(), ]);

const metabaseUserByEmail = Object.fromEntries( metabaseUsers.map((u) => [u.email, u]) );

for (const appUser of appUsers) { const mbUser = metabaseUserByEmail[appUser.email];

if (!mbUser) { // User exists in app but not in Metabase — provision them await createUser({ email: appUser.email, firstName: appUser.firstName, lastName: appUser.lastName, }); continue; }

// Sync group membership const expectedGroupIds = resolveGroupIds(appUser.roles, metabaseGroups); const currentGroupIds = mbUser.group_ids || [];

const toAdd = expectedGroupIds.filter((id) => !currentGroupIds.includes(id)); const toRemove = currentGroupIds.filter((id) => !expectedGroupIds.includes(id) && id !== 1); // never remove from All Users

for (const groupId of toAdd) { await addUserToGroup(mbUser.id, groupId); } // Note: removing group membership requires the membership ID, not user/group IDs } }

---

Best Practices

Use groups, never individual user permissions. Assign permissions to groups and add users to groups. Permissioning individual users creates an unmanageable configuration at scale.

Keep group structure flat. A group hierarchy that's too deep (tenant → sub-team → role → sub-role) becomes hard to reason about. Start with one group per tenant and add sub-groups only when needed.

Treat the permissions graph as code. Store a snapshot of your intended permissions graph in version control and use your sync script to enforce it, rather than accumulating ad-hoc changes.

Test permission changes in staging. The permissions graph is a single object — a mistake can affect all users. Test permission updates on a staging instance before applying to production.

Handle race conditions in bulk provisioning. If multiple users sign up simultaneously and your provisioning code creates groups or reads the permissions graph concurrently, you may hit race conditions. Add locking or queue provisioning operations.

---

Summary

Programmatic user and permission provisioning in Metabase uses three API surfaces: the user API (create/update/deactivate users), the group membership API (manage which users belong to which groups), and the permissions graph (a single JSON structure representing all data access permissions). The key pattern for multi-tenant SaaS deployments is: create a group per tenant, create users with login_attributes matching sandbox policy fields, add users to the tenant group, and configure data access through the permissions graph. Make all provisioning operations idempotent by checking for existing resources before creating new ones.