diff --git a/README.md b/README.md index 3d4afb6..7341ca0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK an ## Usage -`uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `uv run --with lkr-dev-cli[all] lkr --help`. +`uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `uvx --from lkr-dev-cli[all] lkr --help`. Alternatively, you can install `lkr` with `pip install lkr-dev-cli[all]` and use commands directly like `lkr `. @@ -24,7 +24,7 @@ See the [prerequisites section](#oauth2-prerequisites) Login to `lkr` ```bash -uv run --with lkr-dev-cli[all] lkr auth login +uvx --from lkr-dev-cli[all] lkr auth login ``` - Select a new instance @@ -37,7 +37,7 @@ You will be redirected to the Looker OAuth authorization page, click Allow. If y If everything is successful, you will see `Successfully authenticated!`. Test it with ```bash -uv run --with lkr-dev-cli[all] lkr auth whoami +uvx --from lkr-dev-cli[all] lkr auth whoami ``` ### Using API Key @@ -45,7 +45,7 @@ uv run --with lkr-dev-cli[all] lkr auth whoami If you provide environment variables for `LOOKERSDK_CLIENT_ID`, `LOOKERSDK_CLIENT_SECRET`, and `LOOKERSDK_BASE_URL`, `lkr` will use the API key to authenticate and the commands. We also support command line arguments to pass in the client id, client secret, and base url. ```bash -uv run --with lkr-dev-cli[all] lkr --client-id --client-secret --base-url auth whoami +uvx --from lkr-dev-cli[all] lkr --client-id --client-secret --base-url auth whoami ``` @@ -325,6 +325,30 @@ def delete_user_attribute(user_attribute_name: str, email: str): ) updater.delete_user_attribute_value() +## Permission Deprecation Tool + +The `schedule-download-deprecation` tool helps Looker admins ensure that users do not lose access to models they already have when Looker moves towards more granular model-specific permissions for scheduling and downloading. + +### How it helps +Currently, some permissions in Looker can be granted instance-wide. In the future, these permissions may need to be explicitly granted at the model level (via Model Sets). This tool audits all active users and identifies those who: +- Have "target permissions" (like `download_with_limit`, `schedule_look_emails`, etc.) instance-wide. +- Do **not** have those same permissions for specific models they otherwise have access to. + +By running this tool, an admin can proactively identify and fix permission gaps before any deprecation takes effect, ensuring a seamless experience for end-users. + +### Usage +This command should be run by a **Looker Admin**. + +```bash +uvx --from lkr-dev-cli[all] lkr tools schedule-download-deprecation +``` + +Options: +- `--csv`: Export the results to a CSV file for easier analysis of large instances. +- `--unfiltered`: Show all users, including those who have all required permissions across all models. +- `--model-offset`: Slice the table output to show different sets of models (the table shows 5 models at a time). + + ## Optional Dependencies The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once. diff --git a/lkr/tools/main.py b/lkr/tools/main.py index 9f544ae..488883e 100644 --- a/lkr/tools/main.py +++ b/lkr/tools/main.py @@ -1,4 +1,7 @@ import os +import csv +import sys +from typing import Annotated, Optional import typer import uvicorn @@ -6,6 +9,7 @@ from lkr.logger import structured_logger as logger from lkr.tools.classes import AttributeUpdaterResponse, UserAttributeUpdater +from lkr.tools.permission_deprecation import schedule_download_deprecation __all__ = ["group"] @@ -83,5 +87,146 @@ def health(): uvicorn.run(api, host=host, port=port) +PERMISSION_MAP = { + "download_with_limit": "dwl", + "download_without_limit": "dwol", + "schedule_look_emails": "sle", + "schedule_external_look_emails": "sele", + "send_to_s3": "s3", + "send_to_sftp": "sftp", + "send_outgoing_webhook": "hook", + "send_to_integration": "intg", +} + + +def get_visual_length(s: str) -> int: + """Return the visual length of a string, accounting for double-width characters like ✅.""" + # This is a simple heuristic for common emojis used in this tool. + # We count '✅' as width 2, while len() returns 1. + return len(s) + s.count("✅") + + +def visual_ljust(s: str, width: int) -> str: + """Left justify a string based on its visual length.""" + return s + " " * (width - get_visual_length(s)) + + +@group.command(name="schedule-download-deprecation") +def schedule_download_deprecation_command( + ctx: typer.Context, + limit: Annotated[Optional[int], typer.Option(help="Search batch size")] = 500, + model_offset: Annotated[int, typer.Option(help="Offset for model columns")] = 0, + csv_output: Annotated[bool, typer.Option("--csv", help="Output as CSV instead of a table")] = False, + csv_file_name: Annotated[Optional[str], typer.Option("--csv-file-name", help="Name for the output CSV file (without extension)")] = "schedule_download_deprecation", + unfiltered: Annotated[bool, typer.Option("--unfiltered", help="Show all rows, including those with no missing permissions")] = False, + email: Annotated[Optional[bool], typer.Option("--email", help="Use Email instead of Name")] = False, +): + """ + Build a table of users and their scheduling/downloading permissions per model. + """ + result = schedule_download_deprecation(ctx, limit, unfiltered=unfiltered) + if not result: + typer.echo("No matching users found.") + return + + if csv_output: + # For CSV, we ignore pagination and truncation + with open(csv_file_name + ".csv", "w", newline="") as f: + writer = csv.writer(f) + csv_headers = ["User ID", "Email" if email else "Name", "Instance Wide"] + result.model_names + writer.writerow(csv_headers) + + for row in result.rows: + instance_wide = ", ".join(row.instance_wide) if row.instance_wide else " " + model_results = [] + for m_name in result.model_names: + missing = row.model_permissions.get(m_name) + if missing is None: + model_results.append("N/A") + elif not row.has_target_perms: + model_results.append(" ") + elif not missing: + model_results.append("✅") + else: + model_results.append(", ".join(missing)) + + writer.writerow([row.user_id, row.email if email else row.name, instance_wide] + model_results) + typer.echo(f"CSV output written to {csv_file_name}.csv") + return + + # Slice models to only show 5 at a time + total_models = len(result.model_names) + visible_models = result.model_names[model_offset : model_offset + 5] + + # Truncate model names for display + display_model_names = [ + (m if len(m) <= 10 else m[:7] + "...") for m in visible_models + ] + + headers = ["User ID", "Email" if email else "Name", "Instance Wide"] + display_model_names + + # Transform Pydantic rows into visual table rows + table_rows = [] + for row in result.rows: + instance_wide_abbrev = [PERMISSION_MAP.get(p, p) for p in row.instance_wide] + instance_wide_str = "\n".join(instance_wide_abbrev) if instance_wide_abbrev else " " + model_results = [] + for m_name in visible_models: + missing = row.model_permissions.get(m_name) + if missing is None: + model_results.append("N/A") + elif not row.has_target_perms: + model_results.append(" ") + elif not missing: + model_results.append("✅") + else: + missing_abbrev = [PERMISSION_MAP.get(p, p) for p in missing] + model_results.append("\n".join(missing_abbrev)) + + table_rows.append([row.user_id, row.email if email else row.name, instance_wide_str] + model_results) + + # 5. Format and echo the table + col_widths = [ + max(get_visual_length(str(line)) for r in ([headers] + table_rows) for line in str(r[i]).split("\n")) + for i in range(len(headers)) + ] + + def format_line(parts, widths): + return " | ".join(visual_ljust(str(p), w) for p, w in zip(parts, widths)) + + typer.echo(format_line(headers, col_widths)) + typer.echo("-" * (sum(col_widths) + 3 * (len(headers) - 1))) + + if not table_rows: + typer.echo("No users found matching the criteria. 🎉") + return + + for row in table_rows: + max_lines = max(str(cell).count("\n") + 1 for cell in row) + row_lines = [str(cell).split("\n") for cell in row] + + for line_idx in range(max_lines): + line_parts = [ + rl[line_idx] if line_idx < len(rl) else "" + for rl in row_lines + ] + typer.echo(format_line(line_parts, col_widths)) + typer.echo("-" * (sum(col_widths) + 3 * (len(headers) - 1))) + + typer.echo("\n" + "=" * 30) + typer.echo("LEGEND (Shortcuts)") + typer.echo("-" * 30) + for full, short in PERMISSION_MAP.items(): + typer.echo(f"{short.ljust(8)} = {full}") + typer.echo("=" * 30) + + if model_offset + 5 < total_models: + next_offset = model_offset + 5 + typer.echo(f"\nShowing models {model_offset+1}-{min(model_offset+5, total_models)} of {total_models}.") + typer.echo(f"Use --model-offset {next_offset} to see the next 5 models. Or use --csv for the full table.") + elif model_offset > 0: + typer.echo(f"\nShowing models {model_offset+1}-{total_models} of {total_models}.") + + if __name__ == "__main__": group() diff --git a/lkr/tools/permission_deprecation.py b/lkr/tools/permission_deprecation.py new file mode 100644 index 0000000..d824d7b --- /dev/null +++ b/lkr/tools/permission_deprecation.py @@ -0,0 +1,193 @@ +import typer +from typing import List, Optional, Set, Dict +from concurrent.futures import ThreadPoolExecutor +from pydantic import BaseModel +from lkr.auth_service import get_auth +from lkr.logger import logger + +TARGET_PERMISSIONS = frozenset([ + "download_with_limit", + "download_without_limit", + "schedule_look_emails", + "schedule_external_look_emails", + "send_to_s3", + "send_to_sftp", + "send_outgoing_webhook", + "send_to_integration", +]) + +class AuditRow(BaseModel): + user_id: str + name: str + email: Optional[str] = None + instance_wide: List[str] + model_permissions: Dict[str, Optional[List[str]]] # model_name -> missing permissions. None if no 'access_data'. + has_target_perms: bool + +class DeprecationAuditResult(BaseModel): + model_names: List[str] # The columns + rows: List[AuditRow] + +def schedule_download_deprecation( + ctx: typer.Context, + limit: int = 500, + unfiltered: bool = False, +) -> Optional[DeprecationAuditResult]: + """ + Build a audit result of users and their scheduling/downloading permissions per model. + """ + sdk = get_auth(ctx).get_current_sdk() + + # 1. Query models to define columns + logger.info("Fetching LookML models...") + models = sdk.all_lookml_models(fields="name") + model_names = sorted([m.name for m in models if m.name]) + + # 2. Query all roles, permission sets, and model sets + logger.info("Fetching roles and permissions...") + roles = sdk.all_roles(fields="id,name,permission_set,model_set") + + # Pre-process roles for faster lookup + role_map = {} + for role in roles: + target_perms_in_role = set() + has_access_data = False + is_admin = False + + # Check if it's the Admin role (Admin permission set) + if role.permission_set: + if role.permission_set.name == "Admin": + is_admin = True + target_perms_in_role = set(TARGET_PERMISSIONS) + has_access_data = True + elif role.permission_set.permissions: + role_perms = set(role.permission_set.permissions) + target_perms_in_role = set([p for p in TARGET_PERMISSIONS if p in role_perms]) + has_access_data = "access_data" in role_perms + + role_models = set() + all_models = False + if is_admin: + all_models = True + elif role.model_set: + if role.model_set.name == "All" or (role.model_set.models and "*" in role.model_set.models): + all_models = True + elif role.model_set.models: + role_models = set(role.model_set.models) + + role_map[str(role.id)] = { + "target_perms": target_perms_in_role, + "has_access_data": has_access_data, + "models": role_models, + "all_models": all_models + } + + # 3. Pagination - Fetch all non-embed, active users using ThreadPoolExecutor + all_users = [] + offset = 0 + max_workers = 10 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + while True: + logger.info(f"Fetching users batch starting at offset {offset}...") + futures = [] + for i in range(max_workers): + futures.append(executor.submit( + sdk.search_users, + is_disabled=False, + embed_user=False, + limit=limit, + offset=offset + (i * limit), + fields="id,first_name,last_name,role_ids,external_id,email" + )) + + batch_done = False + for future in futures: + users = future.result() + if not users: + batch_done = True + continue + # Filter out embed users locally (embed users have an external_id) + active_non_embed = [u for u in users if not getattr(u, 'external_id', None)] + all_users.extend(active_non_embed) + if len(users) < limit: + batch_done = True + + if batch_done: + break + offset += (max_workers * limit) + + if not all_users: + return None + + # 4. Prepare Result Data + audit_rows = [] + + for user in all_users: + user_id = user.id + name = f"{user.first_name or ''} {user.last_name or ''}".strip() + + user_role_ids = user.role_ids or [] + user_instance_perms: Set[str] = set() + # Track permissions per model + user_model_perms = {m: set() for m in model_names} + # Track if user has access_data per model + user_model_access_data = {m: False for m in model_names} + + for r_id in user_role_ids: + role_info = role_map.get(str(r_id)) + if not role_info: + continue + + p_set = role_info["target_perms"] + has_ad = role_info["has_access_data"] + + # If the role has target perms, add them to instance wide + user_instance_perms.update(p_set) + + # Update per-model tracking + target_models = model_names if role_info["all_models"] else role_info["models"] + for m_name in target_models: + if m_name in model_names: + user_model_perms[m_name].update(p_set) + if has_ad: + user_model_access_data[m_name] = True + + model_results = {} + for m_name in model_names: + if not user_model_access_data[m_name]: + # No access_data for this model -> N/A + model_results[m_name] = None + elif not user_instance_perms: + # User has access_data but no target perms instance-wide + model_results[m_name] = [] + else: + # User has access_data and some target perms instance-wide + # List missing target perms for this model + missing = user_instance_perms - user_model_perms[m_name] + model_results[m_name] = sorted(list(missing)) + + audit_rows.append(AuditRow( + user_id=str(user_id), + name=name, + email=user.email, + instance_wide=sorted(list(user_instance_perms)), + model_permissions=model_results, + has_target_perms=len(user_instance_perms) > 0 + )) + + # 5. Filter Results unless unfiltered is True + if not unfiltered: + # Only show rows where someone has a value in any one of the model_name columns. + # Hide if all are check marks (empty list) or all are N/A (None). + # We check if there's ANY model where the user has missing permissions (non-empty list). + filtered_rows = [ + row for row in audit_rows + if any(row.model_permissions[m] for m in model_names if row.model_permissions[m] is not None) + ] + audit_rows = filtered_rows + + return DeprecationAuditResult( + model_names=model_names, + rows=audit_rows + ) diff --git a/pyproject.toml b/pyproject.toml index d1fee60..cbe000a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,18 +11,15 @@ requires-python = ">=3.12" dependencies = [ "looker-sdk>=25.4.0", "pydantic>=2.11.4", - "pydash>=8.0.5" -] - -[project.optional-dependencies] - -cli = [ + "pydash>=8.0.5", "typer>=0.15.2", "requests>=2.31.0", "cryptography>=42.0.0", "structlog>=25.3.0", "questionary>=2.1.0" ] + +[project.optional-dependencies] mcp = [ "mcp[cli]>=1.9.2", "duckdb>=1.2.2", @@ -36,11 +33,6 @@ tools = [ "fastapi[standard]>=0.115.12" ] all = [ - "typer>=0.15.2", - "requests>=2.31.0", - "cryptography>=42.0.0", - "structlog>=25.3.0", - "questionary>=2.1.0", "mcp[cli]>=1.9.2", "fastapi[standard]>=0.115.12", "selenium>=4.32.0", diff --git a/test.py b/test.py deleted file mode 100644 index ad4a647..0000000 --- a/test.py +++ /dev/null @@ -1,16 +0,0 @@ -from lkr.tools.classes import UserAttributeUpdater - - -updater = UserAttributeUpdater( - user_attribute="test", - value="test", - update_type="default", -) -print(updater.model_dump_json()) - - - - - - - diff --git a/tests/test_permission_deprecation.py b/tests/test_permission_deprecation.py new file mode 100644 index 0000000..253b03f --- /dev/null +++ b/tests/test_permission_deprecation.py @@ -0,0 +1,176 @@ +import pytest +from unittest.mock import MagicMock, patch +from lkr.tools.permission_deprecation import schedule_download_deprecation + +@pytest.fixture +def mock_sdk(): + sdk = MagicMock() + + # Mock models + model_thelook = MagicMock() + model_thelook.name = "thelook" + model_finance = MagicMock() + model_finance.name = "finance" + sdk.all_lookml_models.return_value = [model_thelook, model_finance] + + # User 1: has download_with_limit + access_data on thelook, but ONLY access_data on finance + # Role 1: target + data on thelook + role1 = MagicMock() + role1.id = "1" + role1.permission_set.permissions = ["download_with_limit", "access_data"] + role1.model_set.models = ["thelook"] + + # Role 2: access_data on finance + role2 = MagicMock() + role2.id = "2" + role2.permission_set.permissions = ["access_data"] + role2.model_set.models = ["finance"] + + # User 2: has access_data on thelook, no access_data on finance, has download_with_limit globally or on some other model + # Role 3: download_with_limit on some other model (or we can just use Role 1 but not assign Role 1 to User 2) + # Role 4: access_data on thelook + role4 = MagicMock() + role4.id = "4" + role4.permission_set.permissions = ["access_data"] + role4.model_set.models = ["thelook"] + + # Role 5: download_with_limit on a model User 2 doesn't have access_data for (e.g. they just have the perm) + role5 = MagicMock() + role5.id = "5" + role5.permission_set.permissions = ["download_with_limit"] + role5.model_set.models = ["finance"] # they have the target but NO access_data on finance + + sdk.all_roles.return_value = [role1, role2, role4, role5] + + return sdk + +@patch("lkr.tools.permission_deprecation.get_auth") +def test_user_scenario_with_access_data(mock_get_auth, mock_sdk): + mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk + + # User 1: Role 1 (Target+Data on thelook), Role 2 (Data on finance) + user1 = MagicMock() + user1.id = 1 + user1.first_name = "User" + user1.last_name = "One" + user1.role_ids = ["1", "2"] + user1.external_id = None + + # User 2: Role 4 (Data on thelook), Role 5 (Target on finance) + user2 = MagicMock() + user2.id = 2 + user2.first_name = "User" + user2.last_name = "Two" + user2.role_ids = ["4", "5"] + user2.external_id = None + + def search_side_effect(*args, **kwargs): + offset = kwargs.get('offset', 0) + if offset == 0: + return [user1, user2] + return [] + mock_sdk.search_users.side_effect = search_side_effect + + ctx = MagicMock() + result = schedule_download_deprecation(ctx, limit=500) + + assert result is not None + + # User 1 check + u1 = next(r for r in result.rows if r.user_id == "1") + assert "download_with_limit" in u1.instance_wide + # thelook: Role 1 gives both -> ✅ + assert u1.model_permissions["thelook"] == [] + # finance: Role 2 gives access_data, No role gives download_with_limit on finance -> MISSING + assert "download_with_limit" in u1.model_permissions["finance"] + + # User 2 check + u2 = next(r for r in result.rows if r.user_id == "2") + assert "download_with_limit" in u2.instance_wide + # thelook: Role 4 gives access_data, No role gives target on thelook -> MISSING + assert "download_with_limit" in u2.model_permissions["thelook"] + # finance: Role 5 gives target, but NO ONE gives access_data -> N/A + assert u2.model_permissions["finance"] is None + +@patch("lkr.tools.permission_deprecation.get_auth") +def test_schedule_download_deprecation_admin(mock_get_auth, mock_sdk): + admin_role = MagicMock() + admin_role.id = "admin_id" + admin_role.permission_set.name = "Admin" + admin_role.model_set.models = ["*"] + + user = MagicMock() + user.id = 3 + user.role_ids = ["admin_id"] + user.external_id = None + + mock_sdk.all_roles.return_value = [admin_role] + + def search_side_effect(*args, **kwargs): + offset = kwargs.get('offset', 0) + if offset == 0: + return [user] + return [] + mock_sdk.search_users.side_effect = search_side_effect + + mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk + + ctx = MagicMock() + # Default behavior: Admin is filtered out because they have no missing permissions + result = schedule_download_deprecation(ctx, limit=500) + assert len(result.rows) == 0 + + # With unfiltered=True: Admin should be present + result_unfiltered = schedule_download_deprecation(ctx, limit=500, unfiltered=True) + assert len(result_unfiltered.rows) == 1 + for model in result_unfiltered.model_names: + assert result_unfiltered.rows[0].model_permissions[model] == [] + +@patch("lkr.tools.permission_deprecation.get_auth") +def test_schedule_download_deprecation_filtering_logic(mock_get_auth, mock_sdk): + mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk + + # User A: Has missing permissions (Should always show) + # Role 4 gives access_data on 'thelook' + # Role 5 gives 'download_with_limit' on 'finance' (but not on 'thelook') + # Result: User A will show 'download_with_limit' MISSING on 'thelook' + user_a = MagicMock() + user_a.id = "A" + user_a.role_ids = ["4", "5"] + user_a.external_id = None + + # User B: Has all permissions (Should be filtered) + user_b = MagicMock() + user_b.id = "B" + user_b.role_ids = ["1"] # role 1 has target + access_data on thelook + user_b.external_id = None + + # User C: No access data (Should be filtered) + user_c = MagicMock() + user_c.id = "C" + user_c.role_ids = [] # No roles -> All N/A + user_c.external_id = None + + def search_side_effect(*args, **kwargs): + offset = kwargs.get('offset', 0) + if offset == 0: + return [user_a, user_b, user_c] + return [] + mock_sdk.search_users.side_effect = search_side_effect + + ctx = MagicMock() + + # Test Default (Filtered) + result = schedule_download_deprecation(ctx, limit=500, unfiltered=False) + # Only User A should remain + assert len(result.rows) == 1 + assert result.rows[0].user_id == "A" + + # Test Unfiltered + result_unfiltered = schedule_download_deprecation(ctx, limit=500, unfiltered=True) + # All 3 users should remain + assert len(result_unfiltered.rows) == 3 + ids = [r.user_id for r in result_unfiltered.rows] + assert "A" in ids + assert "B" in ids + assert "C" in ids