Most Looker teams version their LookML meticulously and then configure the instance — groups, roles, model sets, user attributes, connections, SSO mappings — by hand in the Admin UI. That asymmetry is where access incidents come from. Nobody can say who added the Finance group to the all_models model set, when the embed user attribute changed, or whether staging and production actually grant the same things.
This tutorial covers the practical way to close that gap: define instance configuration as code, apply it through the Looker API, and review changes in Git the same way you review a view file. We use the Looker Python SDK for the logic and Terraform for the pieces that map cleanly to declarative resources.
What "admin as code" actually covers
The Looker permission model has four layers, and only the last one lives in LookML:
- Users and groups — identity, usually arriving from SSO/SAML or SCIM.
- Roles — a permission set (what you can do:
explore,develop,manage_models) plus a model set (which models you can do it to). - Content access — folder-level view/manage grants on Shared folders.
- Data access —
access_filter,access_grant, and user attributes evaluated inside LookML at query time.
Layers 1–3 are API-manageable and are exactly what drifts. Layer 4 is already in Git, but it depends on layer 1–3 names: an access_filter keyed on a user attribute is only as trustworthy as the process that sets that attribute. Managing them in the same repository is the point.
Setting up API credentials safely
Create a dedicated service account user in Looker with an admin role, and generate API3 credentials for it. Do not reuse a human's credentials; you want the audit trail in Admin → Activity → API usage to name the automation.
Store the credentials in a secret manager and expose them to the tool as environment variables, never in the repo:
export LOOKERSDK_BASE_URL="https://yourcompany.cloud.looker.com:19999"
export LOOKERSDK_CLIENT_ID="..."
export LOOKERSDK_CLIENT_SECRET="..."
export LOOKERSDK_API_VERSION="4.0"
The SDK reads these automatically, so looker.ini files with secrets in them never need to exist on a CI runner.
A declarative spec file
Start with a single YAML file describing the instance you want. Keep it small and readable — this file is the thing reviewers actually read in a pull request.
# looker/instance.yml
permission_sets:
Analyst:
permissions: [access_data, see_looks, see_user_dashboards, explore, save_content, create_table_calculations, download_with_limit]
Developer:
permissions: [access_data, see_looks, see_user_dashboards, explore, develop, manage_models, save_content, create_table_calculations, download_with_limit]
model_sets:
Core:
models: [core_sales, core_marketing]
Finance:
models: [core_sales, finance_gl]
roles:
Analyst - Core:
permission_set: Analyst
model_set: Core
Developer - Core:
permission_set: Developer
model_set: Core
Analyst - Finance:
permission_set: Analyst
model_set: Finance
groups:
Analytics Team:
roles: [Developer - Core]
Sales Ops:
roles: [Analyst - Core]
Finance:
roles: [Analyst - Finance]
user_attributes:
region:
label: Region
type: string
user_access: none
default: ""
group_values:
Sales Ops: "AMER"
Finance: "ALL"
brand_id:
label: Brand ID
type: number
user_access: none
hidden: true
Two conventions save pain later. First, name roles <Permission Set> - <Model Set>; the name then tells you what it grants without opening it. Second, assign roles to groups only, never to users. Individual role grants are invisible in review and are the single most common finding in the access audits we run.
Reconciling with the Python SDK
The SDK script reads the spec, reads the live instance, and applies the difference. The important design decision is that it is idempotent: running it twice changes nothing the second time.
import os, sys, yaml
import looker_sdk
from looker_sdk import models40 as ml
sdk = looker_sdk.init40()
spec = yaml.safe_load(open("looker/instance.yml"))
DRY_RUN = "--apply" not in sys.argv
changes = []
def log(msg):
changes.append(msg)
print(("[plan] " if DRY_RUN else "[apply] ") + msg)
# --- permission sets -------------------------------------------------
existing_ps = {p.name: p for p in sdk.all_permission_sets()}
for name, cfg in spec.get("permission_sets", {}).items():
want = sorted(cfg["permissions"])
current = existing_ps.get(name)
if current is None:
log(f"create permission_set {name}")
if not DRY_RUN:
sdk.create_permission_set(ml.WritePermissionSet(name=name, permissions=want))
elif sorted(current.permissions or []) != want:
log(f"update permission_set {name}: {sorted(current.permissions or [])} -> {want}")
if not DRY_RUN:
sdk.update_permission_set(current.id, ml.WritePermissionSet(name=name, permissions=want))
# --- model sets ------------------------------------------------------
existing_ms = {m.name: m for m in sdk.all_model_sets()}
for name, cfg in spec.get("model_sets", {}).items():
want = sorted(cfg["models"])
current = existing_ms.get(name)
if current is None:
log(f"create model_set {name}")
if not DRY_RUN:
sdk.create_model_set(ml.WriteModelSet(name=name, models=want))
elif sorted(current.models or []) != want:
log(f"update model_set {name}: {sorted(current.models or [])} -> {want}")
if not DRY_RUN:
sdk.update_model_set(current.id, ml.WriteModelSet(name=name, models=want))
Roles then reference the sets by id, and group membership in roles is set with set_role_groups, which is a full replacement — pass the complete desired list, not a delta:
ps = {p.name: p.id for p in sdk.all_permission_sets()}
ms = {m.name: m.id for m in sdk.all_model_sets()}
roles = {r.name: r for r in sdk.all_roles()}
groups = {g.name: g for g in sdk.all_groups()}
for name, cfg in spec.get("roles", {}).items():
body = ml.WriteRole(
name=name,
permission_set_id=ps[cfg["permission_set"]],
model_set_id=ms[cfg["model_set"]],
)
if name not in roles:
log(f"create role {name}")
if not DRY_RUN:
roles[name] = sdk.create_role(body)
else:
log(f"ensure role {name}")
if not DRY_RUN:
sdk.update_role(roles[name].id, body)
# group -> roles, expressed as role -> groups because that is the API shape
role_to_groups = {}
for gname, gcfg in spec.get("groups", {}).items():
if gname not in groups:
log(f"create group {gname}")
if not DRY_RUN:
groups[gname] = sdk.create_group(ml.WriteGroup(name=gname))
for rname in gcfg.get("roles", []):
role_to_groups.setdefault(rname, []).append(gname)
for rname, gnames in role_to_groups.items():
want_ids = sorted(groups[g].id for g in gnames)
have_ids = sorted(g.id for g in sdk.role_groups(roles[rname].id))
if want_ids != have_ids:
log(f"set role_groups {rname}: {have_ids} -> {want_ids}")
if not DRY_RUN:
sdk.set_role_groups(roles[rname].id, want_ids)
User attributes are the highest-risk part, because LookML access_filter rules read them. Create the attribute, then set group values — group values beat the default, and a user in two groups gets the value from the group with the higher rank:
existing_ua = {u.name: u for u in sdk.all_user_attributes()}
for name, cfg in spec.get("user_attributes", {}).items():
body = ml.WriteUserAttribute(
name=name,
label=cfg.get("label", name),
type=cfg.get("type", "string"),
default_value=cfg.get("default"),
value_is_hidden=cfg.get("hidden", False),
user_can_view=cfg.get("user_access") == "view",
user_can_edit=cfg.get("user_access") == "edit",
)
ua = existing_ua.get(name)
if ua is None:
log(f"create user_attribute {name}")
if not DRY_RUN:
ua = sdk.create_user_attribute(body)
else:
if not DRY_RUN:
ua = sdk.update_user_attribute(ua.id, body)
for gname, value in (cfg.get("group_values") or {}).items():
log(f"set user_attribute {name} for group {gname} = {value}")
if not DRY_RUN:
sdk.update_user_attribute_group_value(
groups[gname].id, ua.id, ml.UserAttributeGroupValue(value=str(value))
)
Notice that nothing in the script deletes. Destructive reconciliation of access control is a bad default: a typo in a group name should not remove three hundred people's access. Report extra objects instead, and let a human delete them:
for name in set(g.name for g in sdk.all_groups()) - set(spec.get("groups", {})) - {"All Users"}:
print(f"[drift] group exists in Looker but not in spec: {name}")
Where Terraform fits
Terraform is a better fit than a script for objects with stable identity and a real lifecycle, and there is a community provider (devoteamgcloud/looker) covering users, groups, roles, permission sets, model sets, and user attributes. The value is the plan output and the state file — you get a reviewable diff for free:
resource "looker_permission_set" "analyst" {
name = "Analyst"
permissions = ["access_data", "see_looks", "see_user_dashboards", "explore", "save_content"]
}
resource "looker_model_set" "core" {
name = "Core"
models = ["core_sales", "core_marketing"]
}
resource "looker_role" "analyst_core" {
name = "Analyst - Core"
permission_set_id = looker_permission_set.analyst.id
model_set_id = looker_model_set.core.id
}
resource "looker_group" "sales_ops" {
name = "Sales Ops"
}
resource "looker_role_groups" "analyst_core" {
role_id = looker_role.analyst_core.id
group_ids = [looker_group.sales_ops.id]
}
A pragmatic split we use on client engagements: Terraform for the permission skeleton (permission sets, model sets, roles, groups, folder grants) because it changes rarely and benefits from state, and SDK scripts for the population (user attribute group values, SCIM reconciliation, embed user provisioning, license cleanup) because those are high-churn and often need conditional logic Terraform expresses badly. Do not manage the same object in both.
Two caveats before you commit. The provider is community-maintained, not Google-supported, so pin the version and read the changelog before upgrading. And connections are best left out of Terraform unless you have a strong secret-management story — a looker_connection resource with warehouse credentials in state is a liability.
Wiring it into CI
The workflow that makes this worth doing:
- Engineer edits
looker/instance.ymlin a branch and opens a PR. - CI runs the script in plan mode (
python reconcile.py) and posts the[plan]lines as a PR comment. Reviewers see "set role_groups Analyst - Finance" in plain English. - Access changes require a review from the data governance owner — enforce with a
CODEOWNERSentry on thelooker/directory. - On merge to main, CI runs
python reconcile.py --applyagainst production, then re-runs plan mode to prove the diff is empty. - A nightly scheduled run executes plan mode only and alerts if it is not empty. That alert is your drift detector: someone changed something in the Admin UI.
Run against a non-production instance first if you have one. If you do not, the plan-mode output plus non-destructive apply is your safety net — which is precisely why the script is built that way.
Verifying the result
Three checks close the loop:
- Permissions: for a representative user in each group, use Admin → Users → Sudo or the
user_attribute_user_valuesendpoint to confirm the attribute values that LookML will actually evaluate. Testingaccess_filterbehaviour without checking the attribute value is guesswork. - Content access: open a Shared folder and confirm grants list groups, not individuals.
- Usage: query System Activity for roles with no group members and user attributes with no consumers. Both accumulate silently; the code-based spec is where you delete them.
Instance configuration is the half of a Looker deployment that most teams never put under version control, and it is the half where mistakes are invisible until they are an incident. A hundred-line spec file, a non-destructive reconcile script, and a nightly drift check turn access control from tribal knowledge into a reviewable artifact.
Want help putting your Looker instance — permissions, user attributes, embed provisioning, and LookML alike — under version control? Vistelio's senior Looker developers do this work every week. Get in touch or read about our Looker health check and technical audit.