lkr-dev-cli 0.0.32__tar.gz → 0.0.33__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/PKG-INFO +35 -17
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/README.md +29 -5
- lkr_dev_cli-0.0.33/lkr/tools/main.py +232 -0
- lkr_dev_cli-0.0.33/lkr/tools/permission_deprecation.py +193 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr.md +21 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/pyproject.toml +4 -12
- lkr_dev_cli-0.0.33/tests/test_permission_deprecation.py +176 -0
- lkr_dev_cli-0.0.32/lkr/tools/main.py +0 -87
- lkr_dev_cli-0.0.32/test.py +0 -16
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.github/workflows/release.yml +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.github/workflows/test-dependencies.yml +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.gitignore +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.python-version +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.vscode/launch.json +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.vscode/settings.json +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/Dockerfile +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/LICENSE +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/Makefile +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/cloudbuild.yaml +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/__init__.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth/__init__.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth/main.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth/oauth.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth_service.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/classes.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/constants.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/custom_types.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/exceptions.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/logger.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/main.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/mcp/classes.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/mcp/main.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/mcp/utils.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/classes.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/embed_container.html +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/main.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/utils.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/tools/classes.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/tests/TESTING.md +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/tests/test_dependency_resolution.py +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/tests/test_deps.sh +0 -0
- {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/uv.lock +0 -0
|
@@ -1,30 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lkr-dev-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.33
|
|
4
4
|
Summary: lkr: a command line interface for looker
|
|
5
5
|
Author: bwebs
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: cryptography>=42.0.0
|
|
9
10
|
Requires-Dist: looker-sdk>=25.4.0
|
|
10
11
|
Requires-Dist: pydantic>=2.11.4
|
|
11
12
|
Requires-Dist: pydash>=8.0.5
|
|
13
|
+
Requires-Dist: questionary>=2.1.0
|
|
14
|
+
Requires-Dist: requests>=2.31.0
|
|
15
|
+
Requires-Dist: structlog>=25.3.0
|
|
16
|
+
Requires-Dist: typer>=0.15.2
|
|
12
17
|
Provides-Extra: all
|
|
13
|
-
Requires-Dist: cryptography>=42.0.0; extra == 'all'
|
|
14
18
|
Requires-Dist: duckdb>=1.2.2; extra == 'all'
|
|
15
19
|
Requires-Dist: fastapi[standard]>=0.115.12; extra == 'all'
|
|
16
20
|
Requires-Dist: mcp[cli]>=1.9.2; extra == 'all'
|
|
17
|
-
Requires-Dist: questionary>=2.1.0; extra == 'all'
|
|
18
|
-
Requires-Dist: requests>=2.31.0; extra == 'all'
|
|
19
21
|
Requires-Dist: selenium>=4.32.0; extra == 'all'
|
|
20
|
-
Requires-Dist: structlog>=25.3.0; extra == 'all'
|
|
21
|
-
Requires-Dist: typer>=0.15.2; extra == 'all'
|
|
22
|
-
Provides-Extra: cli
|
|
23
|
-
Requires-Dist: cryptography>=42.0.0; extra == 'cli'
|
|
24
|
-
Requires-Dist: questionary>=2.1.0; extra == 'cli'
|
|
25
|
-
Requires-Dist: requests>=2.31.0; extra == 'cli'
|
|
26
|
-
Requires-Dist: structlog>=25.3.0; extra == 'cli'
|
|
27
|
-
Requires-Dist: typer>=0.15.2; extra == 'cli'
|
|
28
22
|
Provides-Extra: mcp
|
|
29
23
|
Requires-Dist: duckdb>=1.2.2; extra == 'mcp'
|
|
30
24
|
Requires-Dist: fastapi[standard]>=0.115.12; extra == 'mcp'
|
|
@@ -42,7 +36,7 @@ The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK an
|
|
|
42
36
|
|
|
43
37
|
## Usage
|
|
44
38
|
|
|
45
|
-
`uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `
|
|
39
|
+
`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`.
|
|
46
40
|
|
|
47
41
|
Alternatively, you can install `lkr` with `pip install lkr-dev-cli[all]` and use commands directly like `lkr <command>`.
|
|
48
42
|
|
|
@@ -62,7 +56,7 @@ See the [prerequisites section](#oauth2-prerequisites)
|
|
|
62
56
|
Login to `lkr`
|
|
63
57
|
|
|
64
58
|
```bash
|
|
65
|
-
|
|
59
|
+
uvx --from lkr-dev-cli[all] lkr auth login
|
|
66
60
|
```
|
|
67
61
|
|
|
68
62
|
- Select a new instance
|
|
@@ -70,12 +64,12 @@ uv run --with lkr-dev-cli[all] lkr auth login
|
|
|
70
64
|
- Choose whether you want this login to use production or development mode
|
|
71
65
|
- Give it a name
|
|
72
66
|
|
|
73
|
-
You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#prerequisites) were not done properly.
|
|
67
|
+
You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#oauth2-prerequisites) were not done properly.
|
|
74
68
|
|
|
75
69
|
If everything is successful, you will see `Successfully authenticated!`. Test it with
|
|
76
70
|
|
|
77
71
|
```bash
|
|
78
|
-
|
|
72
|
+
uvx --from lkr-dev-cli[all] lkr auth whoami
|
|
79
73
|
```
|
|
80
74
|
|
|
81
75
|
### Using API Key
|
|
@@ -83,7 +77,7 @@ uv run --with lkr-dev-cli[all] lkr auth whoami
|
|
|
83
77
|
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.
|
|
84
78
|
|
|
85
79
|
```bash
|
|
86
|
-
|
|
80
|
+
uvx --from lkr-dev-cli[all] lkr --client-id <your client id> --client-secret <your client secret> --base-url <your instance url> auth whoami
|
|
87
81
|
```
|
|
88
82
|
|
|
89
83
|
|
|
@@ -363,6 +357,30 @@ def delete_user_attribute(user_attribute_name: str, email: str):
|
|
|
363
357
|
)
|
|
364
358
|
updater.delete_user_attribute_value()
|
|
365
359
|
|
|
360
|
+
## Permission Deprecation Tool
|
|
361
|
+
|
|
362
|
+
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.
|
|
363
|
+
|
|
364
|
+
### How it helps
|
|
365
|
+
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:
|
|
366
|
+
- Have "target permissions" (like `download_with_limit`, `schedule_look_emails`, etc.) instance-wide.
|
|
367
|
+
- Do **not** have those same permissions for specific models they otherwise have access to.
|
|
368
|
+
|
|
369
|
+
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.
|
|
370
|
+
|
|
371
|
+
### Usage
|
|
372
|
+
This command should be run by a **Looker Admin**.
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
uvx --from lkr-dev-cli[all] lkr tools schedule-download-deprecation
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Options:
|
|
379
|
+
- `--csv`: Export the results to a CSV file for easier analysis of large instances.
|
|
380
|
+
- `--unfiltered`: Show all users, including those who have all required permissions across all models.
|
|
381
|
+
- `--model-offset`: Slice the table output to show different sets of models (the table shows 5 models at a time).
|
|
382
|
+
|
|
383
|
+
|
|
366
384
|
## Optional Dependencies
|
|
367
385
|
|
|
368
386
|
The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once.
|
|
@@ -4,7 +4,7 @@ The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK an
|
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
|
-
`uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `
|
|
7
|
+
`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`.
|
|
8
8
|
|
|
9
9
|
Alternatively, you can install `lkr` with `pip install lkr-dev-cli[all]` and use commands directly like `lkr <command>`.
|
|
10
10
|
|
|
@@ -24,7 +24,7 @@ See the [prerequisites section](#oauth2-prerequisites)
|
|
|
24
24
|
Login to `lkr`
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
uvx --from lkr-dev-cli[all] lkr auth login
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
- Select a new instance
|
|
@@ -32,12 +32,12 @@ uv run --with lkr-dev-cli[all] lkr auth login
|
|
|
32
32
|
- Choose whether you want this login to use production or development mode
|
|
33
33
|
- Give it a name
|
|
34
34
|
|
|
35
|
-
You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#prerequisites) were not done properly.
|
|
35
|
+
You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#oauth2-prerequisites) were not done properly.
|
|
36
36
|
|
|
37
37
|
If everything is successful, you will see `Successfully authenticated!`. Test it with
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
|
|
40
|
+
uvx --from lkr-dev-cli[all] lkr auth whoami
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
### Using API Key
|
|
@@ -45,7 +45,7 @@ uv run --with lkr-dev-cli[all] lkr auth whoami
|
|
|
45
45
|
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.
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
|
|
48
|
+
uvx --from lkr-dev-cli[all] lkr --client-id <your client id> --client-secret <your client secret> --base-url <your instance url> auth whoami
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
|
|
@@ -325,6 +325,30 @@ def delete_user_attribute(user_attribute_name: str, email: str):
|
|
|
325
325
|
)
|
|
326
326
|
updater.delete_user_attribute_value()
|
|
327
327
|
|
|
328
|
+
## Permission Deprecation Tool
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
332
|
+
### How it helps
|
|
333
|
+
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:
|
|
334
|
+
- Have "target permissions" (like `download_with_limit`, `schedule_look_emails`, etc.) instance-wide.
|
|
335
|
+
- Do **not** have those same permissions for specific models they otherwise have access to.
|
|
336
|
+
|
|
337
|
+
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.
|
|
338
|
+
|
|
339
|
+
### Usage
|
|
340
|
+
This command should be run by a **Looker Admin**.
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
uvx --from lkr-dev-cli[all] lkr tools schedule-download-deprecation
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Options:
|
|
347
|
+
- `--csv`: Export the results to a CSV file for easier analysis of large instances.
|
|
348
|
+
- `--unfiltered`: Show all users, including those who have all required permissions across all models.
|
|
349
|
+
- `--model-offset`: Slice the table output to show different sets of models (the table shows 5 models at a time).
|
|
350
|
+
|
|
351
|
+
|
|
328
352
|
## Optional Dependencies
|
|
329
353
|
|
|
330
354
|
The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import csv
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
import uvicorn
|
|
8
|
+
from fastapi import FastAPI, Request
|
|
9
|
+
|
|
10
|
+
from lkr.logger import structured_logger as logger
|
|
11
|
+
from lkr.tools.classes import AttributeUpdaterResponse, UserAttributeUpdater
|
|
12
|
+
from lkr.tools.permission_deprecation import schedule_download_deprecation
|
|
13
|
+
|
|
14
|
+
__all__ = ["group"]
|
|
15
|
+
|
|
16
|
+
group = typer.Typer()
|
|
17
|
+
|
|
18
|
+
if not logger:
|
|
19
|
+
raise Exception("Logger is not available")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@group.command()
|
|
23
|
+
def user_attribute_updater(
|
|
24
|
+
host: str = typer.Option(default="127.0.0.1", envvar="HOST"),
|
|
25
|
+
port: int = typer.Option(default=8080, envvar="PORT"),
|
|
26
|
+
):
|
|
27
|
+
api = FastAPI()
|
|
28
|
+
|
|
29
|
+
@api.post("/identity_token")
|
|
30
|
+
def identity_token(request: Request, body: UserAttributeUpdater):
|
|
31
|
+
try:
|
|
32
|
+
body.get_request_authorization_for_value(request.headers.items())
|
|
33
|
+
body.update_user_attribute_value()
|
|
34
|
+
raw_urls = os.getenv("LOOKER_WHITELISTED_BASE_URLS", "")
|
|
35
|
+
whitelisted_base_urls = (
|
|
36
|
+
[url.strip() for url in raw_urls.split(",") if url.strip()]
|
|
37
|
+
if raw_urls
|
|
38
|
+
else []
|
|
39
|
+
)
|
|
40
|
+
logger.debug(
|
|
41
|
+
"identify_token_user_attribute",
|
|
42
|
+
**body.model_dump(),
|
|
43
|
+
whitelisted_base_urls=whitelisted_base_urls,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if body.base_url not in whitelisted_base_urls:
|
|
47
|
+
raise Exception(f"Base URL {body.base_url} not whitelisted")
|
|
48
|
+
|
|
49
|
+
return AttributeUpdaterResponse(
|
|
50
|
+
success=True, message="User attribute updated"
|
|
51
|
+
)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
return AttributeUpdaterResponse(success=False, message=str(e))
|
|
54
|
+
|
|
55
|
+
@api.delete("/value")
|
|
56
|
+
def delete_user_attribute_value(request: Request, body: UserAttributeUpdater):
|
|
57
|
+
try:
|
|
58
|
+
body.delete_user_attribute_value()
|
|
59
|
+
logger.debug(
|
|
60
|
+
"user_attribute_delete",
|
|
61
|
+
**body.model_dump(),
|
|
62
|
+
)
|
|
63
|
+
return AttributeUpdaterResponse(
|
|
64
|
+
success=True, message="User attribute value deleted"
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return AttributeUpdaterResponse(success=False, message=str(e))
|
|
68
|
+
|
|
69
|
+
@api.post("/value")
|
|
70
|
+
def update_user_attribute_value(request: Request, body: UserAttributeUpdater):
|
|
71
|
+
try:
|
|
72
|
+
body.update_user_attribute_value()
|
|
73
|
+
logger.debug(
|
|
74
|
+
"user_attribute_update",
|
|
75
|
+
**body.model_dump(),
|
|
76
|
+
)
|
|
77
|
+
return AttributeUpdaterResponse(
|
|
78
|
+
success=True, message="User attribute value updated"
|
|
79
|
+
)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return AttributeUpdaterResponse(success=False, message=str(e))
|
|
82
|
+
|
|
83
|
+
@api.get("")
|
|
84
|
+
def health():
|
|
85
|
+
return {"status": "ok"}
|
|
86
|
+
|
|
87
|
+
uvicorn.run(api, host=host, port=port)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
PERMISSION_MAP = {
|
|
91
|
+
"download_with_limit": "dwl",
|
|
92
|
+
"download_without_limit": "dwol",
|
|
93
|
+
"schedule_look_emails": "sle",
|
|
94
|
+
"schedule_external_look_emails": "sele",
|
|
95
|
+
"send_to_s3": "s3",
|
|
96
|
+
"send_to_sftp": "sftp",
|
|
97
|
+
"send_outgoing_webhook": "hook",
|
|
98
|
+
"send_to_integration": "intg",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_visual_length(s: str) -> int:
|
|
103
|
+
"""Return the visual length of a string, accounting for double-width characters like ✅."""
|
|
104
|
+
# This is a simple heuristic for common emojis used in this tool.
|
|
105
|
+
# We count '✅' as width 2, while len() returns 1.
|
|
106
|
+
return len(s) + s.count("✅")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def visual_ljust(s: str, width: int) -> str:
|
|
110
|
+
"""Left justify a string based on its visual length."""
|
|
111
|
+
return s + " " * (width - get_visual_length(s))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@group.command(name="schedule-download-deprecation")
|
|
115
|
+
def schedule_download_deprecation_command(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
limit: Annotated[Optional[int], typer.Option(help="Search batch size")] = 500,
|
|
118
|
+
model_offset: Annotated[int, typer.Option(help="Offset for model columns")] = 0,
|
|
119
|
+
csv_output: Annotated[bool, typer.Option("--csv", help="Output as CSV instead of a table")] = False,
|
|
120
|
+
csv_file_name: Annotated[Optional[str], typer.Option("--csv-file-name", help="Name for the output CSV file (without extension)")] = "schedule_download_deprecation",
|
|
121
|
+
unfiltered: Annotated[bool, typer.Option("--unfiltered", help="Show all rows, including those with no missing permissions")] = False,
|
|
122
|
+
email: Annotated[Optional[bool], typer.Option("--email", help="Use Email instead of Name")] = False,
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Build a table of users and their scheduling/downloading permissions per model.
|
|
126
|
+
"""
|
|
127
|
+
result = schedule_download_deprecation(ctx, limit, unfiltered=unfiltered)
|
|
128
|
+
if not result:
|
|
129
|
+
typer.echo("No matching users found.")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if csv_output:
|
|
133
|
+
# For CSV, we ignore pagination and truncation
|
|
134
|
+
with open(csv_file_name + ".csv", "w", newline="") as f:
|
|
135
|
+
writer = csv.writer(f)
|
|
136
|
+
csv_headers = ["User ID", "Email" if email else "Name", "Instance Wide"] + result.model_names
|
|
137
|
+
writer.writerow(csv_headers)
|
|
138
|
+
|
|
139
|
+
for row in result.rows:
|
|
140
|
+
instance_wide = ", ".join(row.instance_wide) if row.instance_wide else " "
|
|
141
|
+
model_results = []
|
|
142
|
+
for m_name in result.model_names:
|
|
143
|
+
missing = row.model_permissions.get(m_name)
|
|
144
|
+
if missing is None:
|
|
145
|
+
model_results.append("N/A")
|
|
146
|
+
elif not row.has_target_perms:
|
|
147
|
+
model_results.append(" ")
|
|
148
|
+
elif not missing:
|
|
149
|
+
model_results.append("✅")
|
|
150
|
+
else:
|
|
151
|
+
model_results.append(", ".join(missing))
|
|
152
|
+
|
|
153
|
+
writer.writerow([row.user_id, row.email if email else row.name, instance_wide] + model_results)
|
|
154
|
+
typer.echo(f"CSV output written to {csv_file_name}.csv")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Slice models to only show 5 at a time
|
|
158
|
+
total_models = len(result.model_names)
|
|
159
|
+
visible_models = result.model_names[model_offset : model_offset + 5]
|
|
160
|
+
|
|
161
|
+
# Truncate model names for display
|
|
162
|
+
display_model_names = [
|
|
163
|
+
(m if len(m) <= 10 else m[:7] + "...") for m in visible_models
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
headers = ["User ID", "Email" if email else "Name", "Instance Wide"] + display_model_names
|
|
167
|
+
|
|
168
|
+
# Transform Pydantic rows into visual table rows
|
|
169
|
+
table_rows = []
|
|
170
|
+
for row in result.rows:
|
|
171
|
+
instance_wide_abbrev = [PERMISSION_MAP.get(p, p) for p in row.instance_wide]
|
|
172
|
+
instance_wide_str = "\n".join(instance_wide_abbrev) if instance_wide_abbrev else " "
|
|
173
|
+
model_results = []
|
|
174
|
+
for m_name in visible_models:
|
|
175
|
+
missing = row.model_permissions.get(m_name)
|
|
176
|
+
if missing is None:
|
|
177
|
+
model_results.append("N/A")
|
|
178
|
+
elif not row.has_target_perms:
|
|
179
|
+
model_results.append(" ")
|
|
180
|
+
elif not missing:
|
|
181
|
+
model_results.append("✅")
|
|
182
|
+
else:
|
|
183
|
+
missing_abbrev = [PERMISSION_MAP.get(p, p) for p in missing]
|
|
184
|
+
model_results.append("\n".join(missing_abbrev))
|
|
185
|
+
|
|
186
|
+
table_rows.append([row.user_id, row.email if email else row.name, instance_wide_str] + model_results)
|
|
187
|
+
|
|
188
|
+
# 5. Format and echo the table
|
|
189
|
+
col_widths = [
|
|
190
|
+
max(get_visual_length(str(line)) for r in ([headers] + table_rows) for line in str(r[i]).split("\n"))
|
|
191
|
+
for i in range(len(headers))
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
def format_line(parts, widths):
|
|
195
|
+
return " | ".join(visual_ljust(str(p), w) for p, w in zip(parts, widths))
|
|
196
|
+
|
|
197
|
+
typer.echo(format_line(headers, col_widths))
|
|
198
|
+
typer.echo("-" * (sum(col_widths) + 3 * (len(headers) - 1)))
|
|
199
|
+
|
|
200
|
+
if not table_rows:
|
|
201
|
+
typer.echo("No users found matching the criteria. 🎉")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
for row in table_rows:
|
|
205
|
+
max_lines = max(str(cell).count("\n") + 1 for cell in row)
|
|
206
|
+
row_lines = [str(cell).split("\n") for cell in row]
|
|
207
|
+
|
|
208
|
+
for line_idx in range(max_lines):
|
|
209
|
+
line_parts = [
|
|
210
|
+
rl[line_idx] if line_idx < len(rl) else ""
|
|
211
|
+
for rl in row_lines
|
|
212
|
+
]
|
|
213
|
+
typer.echo(format_line(line_parts, col_widths))
|
|
214
|
+
typer.echo("-" * (sum(col_widths) + 3 * (len(headers) - 1)))
|
|
215
|
+
|
|
216
|
+
typer.echo("\n" + "=" * 30)
|
|
217
|
+
typer.echo("LEGEND (Shortcuts)")
|
|
218
|
+
typer.echo("-" * 30)
|
|
219
|
+
for full, short in PERMISSION_MAP.items():
|
|
220
|
+
typer.echo(f"{short.ljust(8)} = {full}")
|
|
221
|
+
typer.echo("=" * 30)
|
|
222
|
+
|
|
223
|
+
if model_offset + 5 < total_models:
|
|
224
|
+
next_offset = model_offset + 5
|
|
225
|
+
typer.echo(f"\nShowing models {model_offset+1}-{min(model_offset+5, total_models)} of {total_models}.")
|
|
226
|
+
typer.echo(f"Use --model-offset {next_offset} to see the next 5 models. Or use --csv for the full table.")
|
|
227
|
+
elif model_offset > 0:
|
|
228
|
+
typer.echo(f"\nShowing models {model_offset+1}-{total_models} of {total_models}.")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
group()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import List, Optional, Set, Dict
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from lkr.auth_service import get_auth
|
|
6
|
+
from lkr.logger import logger
|
|
7
|
+
|
|
8
|
+
TARGET_PERMISSIONS = frozenset([
|
|
9
|
+
"download_with_limit",
|
|
10
|
+
"download_without_limit",
|
|
11
|
+
"schedule_look_emails",
|
|
12
|
+
"schedule_external_look_emails",
|
|
13
|
+
"send_to_s3",
|
|
14
|
+
"send_to_sftp",
|
|
15
|
+
"send_outgoing_webhook",
|
|
16
|
+
"send_to_integration",
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
class AuditRow(BaseModel):
|
|
20
|
+
user_id: str
|
|
21
|
+
name: str
|
|
22
|
+
email: Optional[str] = None
|
|
23
|
+
instance_wide: List[str]
|
|
24
|
+
model_permissions: Dict[str, Optional[List[str]]] # model_name -> missing permissions. None if no 'access_data'.
|
|
25
|
+
has_target_perms: bool
|
|
26
|
+
|
|
27
|
+
class DeprecationAuditResult(BaseModel):
|
|
28
|
+
model_names: List[str] # The columns
|
|
29
|
+
rows: List[AuditRow]
|
|
30
|
+
|
|
31
|
+
def schedule_download_deprecation(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
limit: int = 500,
|
|
34
|
+
unfiltered: bool = False,
|
|
35
|
+
) -> Optional[DeprecationAuditResult]:
|
|
36
|
+
"""
|
|
37
|
+
Build a audit result of users and their scheduling/downloading permissions per model.
|
|
38
|
+
"""
|
|
39
|
+
sdk = get_auth(ctx).get_current_sdk()
|
|
40
|
+
|
|
41
|
+
# 1. Query models to define columns
|
|
42
|
+
logger.info("Fetching LookML models...")
|
|
43
|
+
models = sdk.all_lookml_models(fields="name")
|
|
44
|
+
model_names = sorted([m.name for m in models if m.name])
|
|
45
|
+
|
|
46
|
+
# 2. Query all roles, permission sets, and model sets
|
|
47
|
+
logger.info("Fetching roles and permissions...")
|
|
48
|
+
roles = sdk.all_roles(fields="id,name,permission_set,model_set")
|
|
49
|
+
|
|
50
|
+
# Pre-process roles for faster lookup
|
|
51
|
+
role_map = {}
|
|
52
|
+
for role in roles:
|
|
53
|
+
target_perms_in_role = set()
|
|
54
|
+
has_access_data = False
|
|
55
|
+
is_admin = False
|
|
56
|
+
|
|
57
|
+
# Check if it's the Admin role (Admin permission set)
|
|
58
|
+
if role.permission_set:
|
|
59
|
+
if role.permission_set.name == "Admin":
|
|
60
|
+
is_admin = True
|
|
61
|
+
target_perms_in_role = set(TARGET_PERMISSIONS)
|
|
62
|
+
has_access_data = True
|
|
63
|
+
elif role.permission_set.permissions:
|
|
64
|
+
role_perms = set(role.permission_set.permissions)
|
|
65
|
+
target_perms_in_role = set([p for p in TARGET_PERMISSIONS if p in role_perms])
|
|
66
|
+
has_access_data = "access_data" in role_perms
|
|
67
|
+
|
|
68
|
+
role_models = set()
|
|
69
|
+
all_models = False
|
|
70
|
+
if is_admin:
|
|
71
|
+
all_models = True
|
|
72
|
+
elif role.model_set:
|
|
73
|
+
if role.model_set.name == "All" or (role.model_set.models and "*" in role.model_set.models):
|
|
74
|
+
all_models = True
|
|
75
|
+
elif role.model_set.models:
|
|
76
|
+
role_models = set(role.model_set.models)
|
|
77
|
+
|
|
78
|
+
role_map[str(role.id)] = {
|
|
79
|
+
"target_perms": target_perms_in_role,
|
|
80
|
+
"has_access_data": has_access_data,
|
|
81
|
+
"models": role_models,
|
|
82
|
+
"all_models": all_models
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# 3. Pagination - Fetch all non-embed, active users using ThreadPoolExecutor
|
|
86
|
+
all_users = []
|
|
87
|
+
offset = 0
|
|
88
|
+
max_workers = 10
|
|
89
|
+
|
|
90
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
91
|
+
while True:
|
|
92
|
+
logger.info(f"Fetching users batch starting at offset {offset}...")
|
|
93
|
+
futures = []
|
|
94
|
+
for i in range(max_workers):
|
|
95
|
+
futures.append(executor.submit(
|
|
96
|
+
sdk.search_users,
|
|
97
|
+
is_disabled=False,
|
|
98
|
+
embed_user=False,
|
|
99
|
+
limit=limit,
|
|
100
|
+
offset=offset + (i * limit),
|
|
101
|
+
fields="id,first_name,last_name,role_ids,external_id,email"
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
batch_done = False
|
|
105
|
+
for future in futures:
|
|
106
|
+
users = future.result()
|
|
107
|
+
if not users:
|
|
108
|
+
batch_done = True
|
|
109
|
+
continue
|
|
110
|
+
# Filter out embed users locally (embed users have an external_id)
|
|
111
|
+
active_non_embed = [u for u in users if not getattr(u, 'external_id', None)]
|
|
112
|
+
all_users.extend(active_non_embed)
|
|
113
|
+
if len(users) < limit:
|
|
114
|
+
batch_done = True
|
|
115
|
+
|
|
116
|
+
if batch_done:
|
|
117
|
+
break
|
|
118
|
+
offset += (max_workers * limit)
|
|
119
|
+
|
|
120
|
+
if not all_users:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# 4. Prepare Result Data
|
|
124
|
+
audit_rows = []
|
|
125
|
+
|
|
126
|
+
for user in all_users:
|
|
127
|
+
user_id = user.id
|
|
128
|
+
name = f"{user.first_name or ''} {user.last_name or ''}".strip()
|
|
129
|
+
|
|
130
|
+
user_role_ids = user.role_ids or []
|
|
131
|
+
user_instance_perms: Set[str] = set()
|
|
132
|
+
# Track permissions per model
|
|
133
|
+
user_model_perms = {m: set() for m in model_names}
|
|
134
|
+
# Track if user has access_data per model
|
|
135
|
+
user_model_access_data = {m: False for m in model_names}
|
|
136
|
+
|
|
137
|
+
for r_id in user_role_ids:
|
|
138
|
+
role_info = role_map.get(str(r_id))
|
|
139
|
+
if not role_info:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
p_set = role_info["target_perms"]
|
|
143
|
+
has_ad = role_info["has_access_data"]
|
|
144
|
+
|
|
145
|
+
# If the role has target perms, add them to instance wide
|
|
146
|
+
user_instance_perms.update(p_set)
|
|
147
|
+
|
|
148
|
+
# Update per-model tracking
|
|
149
|
+
target_models = model_names if role_info["all_models"] else role_info["models"]
|
|
150
|
+
for m_name in target_models:
|
|
151
|
+
if m_name in model_names:
|
|
152
|
+
user_model_perms[m_name].update(p_set)
|
|
153
|
+
if has_ad:
|
|
154
|
+
user_model_access_data[m_name] = True
|
|
155
|
+
|
|
156
|
+
model_results = {}
|
|
157
|
+
for m_name in model_names:
|
|
158
|
+
if not user_model_access_data[m_name]:
|
|
159
|
+
# No access_data for this model -> N/A
|
|
160
|
+
model_results[m_name] = None
|
|
161
|
+
elif not user_instance_perms:
|
|
162
|
+
# User has access_data but no target perms instance-wide
|
|
163
|
+
model_results[m_name] = []
|
|
164
|
+
else:
|
|
165
|
+
# User has access_data and some target perms instance-wide
|
|
166
|
+
# List missing target perms for this model
|
|
167
|
+
missing = user_instance_perms - user_model_perms[m_name]
|
|
168
|
+
model_results[m_name] = sorted(list(missing))
|
|
169
|
+
|
|
170
|
+
audit_rows.append(AuditRow(
|
|
171
|
+
user_id=str(user_id),
|
|
172
|
+
name=name,
|
|
173
|
+
email=user.email,
|
|
174
|
+
instance_wide=sorted(list(user_instance_perms)),
|
|
175
|
+
model_permissions=model_results,
|
|
176
|
+
has_target_perms=len(user_instance_perms) > 0
|
|
177
|
+
))
|
|
178
|
+
|
|
179
|
+
# 5. Filter Results unless unfiltered is True
|
|
180
|
+
if not unfiltered:
|
|
181
|
+
# Only show rows where someone has a value in any one of the model_name columns.
|
|
182
|
+
# Hide if all are check marks (empty list) or all are N/A (None).
|
|
183
|
+
# We check if there's ANY model where the user has missing permissions (non-empty list).
|
|
184
|
+
filtered_rows = [
|
|
185
|
+
row for row in audit_rows
|
|
186
|
+
if any(row.model_permissions[m] for m in model_names if row.model_permissions[m] is not None)
|
|
187
|
+
]
|
|
188
|
+
audit_rows = filtered_rows
|
|
189
|
+
|
|
190
|
+
return DeprecationAuditResult(
|
|
191
|
+
model_names=model_names,
|
|
192
|
+
rows=audit_rows
|
|
193
|
+
)
|
|
@@ -186,6 +186,7 @@ $ lkr tools [OPTIONS] COMMAND [ARGS]...
|
|
|
186
186
|
**Commands**:
|
|
187
187
|
|
|
188
188
|
* `user-attribute-updater`
|
|
189
|
+
* `schedule-download-deprecation`: Build a table of users and their...
|
|
189
190
|
|
|
190
191
|
### `lkr tools user-attribute-updater`
|
|
191
192
|
|
|
@@ -200,3 +201,23 @@ $ lkr tools user-attribute-updater [OPTIONS]
|
|
|
200
201
|
* `--host TEXT`: [env var: HOST; default: 127.0.0.1]
|
|
201
202
|
* `--port INTEGER`: [env var: PORT; default: 8080]
|
|
202
203
|
* `--help`: Show this message and exit.
|
|
204
|
+
|
|
205
|
+
### `lkr tools schedule-download-deprecation`
|
|
206
|
+
|
|
207
|
+
Build a table of users and their scheduling/downloading permissions per model.
|
|
208
|
+
|
|
209
|
+
**Usage**:
|
|
210
|
+
|
|
211
|
+
```console
|
|
212
|
+
$ lkr tools schedule-download-deprecation [OPTIONS]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Options**:
|
|
216
|
+
|
|
217
|
+
* `--limit INTEGER`: Search batch size [default: 500]
|
|
218
|
+
* `--model-offset INTEGER`: Offset for model columns [default: 0]
|
|
219
|
+
* `--csv`: Output as CSV instead of a table
|
|
220
|
+
* `--csv-file-name TEXT`: Name for the output CSV file (without extension) [default: schedule_download_deprecation]
|
|
221
|
+
* `--unfiltered`: Show all rows, including those with no missing permissions
|
|
222
|
+
* `--email`: Use Email instead of Name
|
|
223
|
+
* `--help`: Show this message and exit.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lkr-dev-cli"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.33"
|
|
4
4
|
description = "lkr: a command line interface for looker"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -11,18 +11,15 @@ requires-python = ">=3.12"
|
|
|
11
11
|
dependencies = [
|
|
12
12
|
"looker-sdk>=25.4.0",
|
|
13
13
|
"pydantic>=2.11.4",
|
|
14
|
-
"pydash>=8.0.5"
|
|
15
|
-
]
|
|
16
|
-
|
|
17
|
-
[project.optional-dependencies]
|
|
18
|
-
|
|
19
|
-
cli = [
|
|
14
|
+
"pydash>=8.0.5",
|
|
20
15
|
"typer>=0.15.2",
|
|
21
16
|
"requests>=2.31.0",
|
|
22
17
|
"cryptography>=42.0.0",
|
|
23
18
|
"structlog>=25.3.0",
|
|
24
19
|
"questionary>=2.1.0"
|
|
25
20
|
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
26
23
|
mcp = [
|
|
27
24
|
"mcp[cli]>=1.9.2",
|
|
28
25
|
"duckdb>=1.2.2",
|
|
@@ -36,11 +33,6 @@ tools = [
|
|
|
36
33
|
"fastapi[standard]>=0.115.12"
|
|
37
34
|
]
|
|
38
35
|
all = [
|
|
39
|
-
"typer>=0.15.2",
|
|
40
|
-
"requests>=2.31.0",
|
|
41
|
-
"cryptography>=42.0.0",
|
|
42
|
-
"structlog>=25.3.0",
|
|
43
|
-
"questionary>=2.1.0",
|
|
44
36
|
"mcp[cli]>=1.9.2",
|
|
45
37
|
"fastapi[standard]>=0.115.12",
|
|
46
38
|
"selenium>=4.32.0",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
from lkr.tools.permission_deprecation import schedule_download_deprecation
|
|
4
|
+
|
|
5
|
+
@pytest.fixture
|
|
6
|
+
def mock_sdk():
|
|
7
|
+
sdk = MagicMock()
|
|
8
|
+
|
|
9
|
+
# Mock models
|
|
10
|
+
model_thelook = MagicMock()
|
|
11
|
+
model_thelook.name = "thelook"
|
|
12
|
+
model_finance = MagicMock()
|
|
13
|
+
model_finance.name = "finance"
|
|
14
|
+
sdk.all_lookml_models.return_value = [model_thelook, model_finance]
|
|
15
|
+
|
|
16
|
+
# User 1: has download_with_limit + access_data on thelook, but ONLY access_data on finance
|
|
17
|
+
# Role 1: target + data on thelook
|
|
18
|
+
role1 = MagicMock()
|
|
19
|
+
role1.id = "1"
|
|
20
|
+
role1.permission_set.permissions = ["download_with_limit", "access_data"]
|
|
21
|
+
role1.model_set.models = ["thelook"]
|
|
22
|
+
|
|
23
|
+
# Role 2: access_data on finance
|
|
24
|
+
role2 = MagicMock()
|
|
25
|
+
role2.id = "2"
|
|
26
|
+
role2.permission_set.permissions = ["access_data"]
|
|
27
|
+
role2.model_set.models = ["finance"]
|
|
28
|
+
|
|
29
|
+
# User 2: has access_data on thelook, no access_data on finance, has download_with_limit globally or on some other model
|
|
30
|
+
# Role 3: download_with_limit on some other model (or we can just use Role 1 but not assign Role 1 to User 2)
|
|
31
|
+
# Role 4: access_data on thelook
|
|
32
|
+
role4 = MagicMock()
|
|
33
|
+
role4.id = "4"
|
|
34
|
+
role4.permission_set.permissions = ["access_data"]
|
|
35
|
+
role4.model_set.models = ["thelook"]
|
|
36
|
+
|
|
37
|
+
# Role 5: download_with_limit on a model User 2 doesn't have access_data for (e.g. they just have the perm)
|
|
38
|
+
role5 = MagicMock()
|
|
39
|
+
role5.id = "5"
|
|
40
|
+
role5.permission_set.permissions = ["download_with_limit"]
|
|
41
|
+
role5.model_set.models = ["finance"] # they have the target but NO access_data on finance
|
|
42
|
+
|
|
43
|
+
sdk.all_roles.return_value = [role1, role2, role4, role5]
|
|
44
|
+
|
|
45
|
+
return sdk
|
|
46
|
+
|
|
47
|
+
@patch("lkr.tools.permission_deprecation.get_auth")
|
|
48
|
+
def test_user_scenario_with_access_data(mock_get_auth, mock_sdk):
|
|
49
|
+
mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk
|
|
50
|
+
|
|
51
|
+
# User 1: Role 1 (Target+Data on thelook), Role 2 (Data on finance)
|
|
52
|
+
user1 = MagicMock()
|
|
53
|
+
user1.id = 1
|
|
54
|
+
user1.first_name = "User"
|
|
55
|
+
user1.last_name = "One"
|
|
56
|
+
user1.role_ids = ["1", "2"]
|
|
57
|
+
user1.external_id = None
|
|
58
|
+
|
|
59
|
+
# User 2: Role 4 (Data on thelook), Role 5 (Target on finance)
|
|
60
|
+
user2 = MagicMock()
|
|
61
|
+
user2.id = 2
|
|
62
|
+
user2.first_name = "User"
|
|
63
|
+
user2.last_name = "Two"
|
|
64
|
+
user2.role_ids = ["4", "5"]
|
|
65
|
+
user2.external_id = None
|
|
66
|
+
|
|
67
|
+
def search_side_effect(*args, **kwargs):
|
|
68
|
+
offset = kwargs.get('offset', 0)
|
|
69
|
+
if offset == 0:
|
|
70
|
+
return [user1, user2]
|
|
71
|
+
return []
|
|
72
|
+
mock_sdk.search_users.side_effect = search_side_effect
|
|
73
|
+
|
|
74
|
+
ctx = MagicMock()
|
|
75
|
+
result = schedule_download_deprecation(ctx, limit=500)
|
|
76
|
+
|
|
77
|
+
assert result is not None
|
|
78
|
+
|
|
79
|
+
# User 1 check
|
|
80
|
+
u1 = next(r for r in result.rows if r.user_id == "1")
|
|
81
|
+
assert "download_with_limit" in u1.instance_wide
|
|
82
|
+
# thelook: Role 1 gives both -> ✅
|
|
83
|
+
assert u1.model_permissions["thelook"] == []
|
|
84
|
+
# finance: Role 2 gives access_data, No role gives download_with_limit on finance -> MISSING
|
|
85
|
+
assert "download_with_limit" in u1.model_permissions["finance"]
|
|
86
|
+
|
|
87
|
+
# User 2 check
|
|
88
|
+
u2 = next(r for r in result.rows if r.user_id == "2")
|
|
89
|
+
assert "download_with_limit" in u2.instance_wide
|
|
90
|
+
# thelook: Role 4 gives access_data, No role gives target on thelook -> MISSING
|
|
91
|
+
assert "download_with_limit" in u2.model_permissions["thelook"]
|
|
92
|
+
# finance: Role 5 gives target, but NO ONE gives access_data -> N/A
|
|
93
|
+
assert u2.model_permissions["finance"] is None
|
|
94
|
+
|
|
95
|
+
@patch("lkr.tools.permission_deprecation.get_auth")
|
|
96
|
+
def test_schedule_download_deprecation_admin(mock_get_auth, mock_sdk):
|
|
97
|
+
admin_role = MagicMock()
|
|
98
|
+
admin_role.id = "admin_id"
|
|
99
|
+
admin_role.permission_set.name = "Admin"
|
|
100
|
+
admin_role.model_set.models = ["*"]
|
|
101
|
+
|
|
102
|
+
user = MagicMock()
|
|
103
|
+
user.id = 3
|
|
104
|
+
user.role_ids = ["admin_id"]
|
|
105
|
+
user.external_id = None
|
|
106
|
+
|
|
107
|
+
mock_sdk.all_roles.return_value = [admin_role]
|
|
108
|
+
|
|
109
|
+
def search_side_effect(*args, **kwargs):
|
|
110
|
+
offset = kwargs.get('offset', 0)
|
|
111
|
+
if offset == 0:
|
|
112
|
+
return [user]
|
|
113
|
+
return []
|
|
114
|
+
mock_sdk.search_users.side_effect = search_side_effect
|
|
115
|
+
|
|
116
|
+
mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk
|
|
117
|
+
|
|
118
|
+
ctx = MagicMock()
|
|
119
|
+
# Default behavior: Admin is filtered out because they have no missing permissions
|
|
120
|
+
result = schedule_download_deprecation(ctx, limit=500)
|
|
121
|
+
assert len(result.rows) == 0
|
|
122
|
+
|
|
123
|
+
# With unfiltered=True: Admin should be present
|
|
124
|
+
result_unfiltered = schedule_download_deprecation(ctx, limit=500, unfiltered=True)
|
|
125
|
+
assert len(result_unfiltered.rows) == 1
|
|
126
|
+
for model in result_unfiltered.model_names:
|
|
127
|
+
assert result_unfiltered.rows[0].model_permissions[model] == []
|
|
128
|
+
|
|
129
|
+
@patch("lkr.tools.permission_deprecation.get_auth")
|
|
130
|
+
def test_schedule_download_deprecation_filtering_logic(mock_get_auth, mock_sdk):
|
|
131
|
+
mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk
|
|
132
|
+
|
|
133
|
+
# User A: Has missing permissions (Should always show)
|
|
134
|
+
# Role 4 gives access_data on 'thelook'
|
|
135
|
+
# Role 5 gives 'download_with_limit' on 'finance' (but not on 'thelook')
|
|
136
|
+
# Result: User A will show 'download_with_limit' MISSING on 'thelook'
|
|
137
|
+
user_a = MagicMock()
|
|
138
|
+
user_a.id = "A"
|
|
139
|
+
user_a.role_ids = ["4", "5"]
|
|
140
|
+
user_a.external_id = None
|
|
141
|
+
|
|
142
|
+
# User B: Has all permissions (Should be filtered)
|
|
143
|
+
user_b = MagicMock()
|
|
144
|
+
user_b.id = "B"
|
|
145
|
+
user_b.role_ids = ["1"] # role 1 has target + access_data on thelook
|
|
146
|
+
user_b.external_id = None
|
|
147
|
+
|
|
148
|
+
# User C: No access data (Should be filtered)
|
|
149
|
+
user_c = MagicMock()
|
|
150
|
+
user_c.id = "C"
|
|
151
|
+
user_c.role_ids = [] # No roles -> All N/A
|
|
152
|
+
user_c.external_id = None
|
|
153
|
+
|
|
154
|
+
def search_side_effect(*args, **kwargs):
|
|
155
|
+
offset = kwargs.get('offset', 0)
|
|
156
|
+
if offset == 0:
|
|
157
|
+
return [user_a, user_b, user_c]
|
|
158
|
+
return []
|
|
159
|
+
mock_sdk.search_users.side_effect = search_side_effect
|
|
160
|
+
|
|
161
|
+
ctx = MagicMock()
|
|
162
|
+
|
|
163
|
+
# Test Default (Filtered)
|
|
164
|
+
result = schedule_download_deprecation(ctx, limit=500, unfiltered=False)
|
|
165
|
+
# Only User A should remain
|
|
166
|
+
assert len(result.rows) == 1
|
|
167
|
+
assert result.rows[0].user_id == "A"
|
|
168
|
+
|
|
169
|
+
# Test Unfiltered
|
|
170
|
+
result_unfiltered = schedule_download_deprecation(ctx, limit=500, unfiltered=True)
|
|
171
|
+
# All 3 users should remain
|
|
172
|
+
assert len(result_unfiltered.rows) == 3
|
|
173
|
+
ids = [r.user_id for r in result_unfiltered.rows]
|
|
174
|
+
assert "A" in ids
|
|
175
|
+
assert "B" in ids
|
|
176
|
+
assert "C" in ids
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
import typer
|
|
4
|
-
import uvicorn
|
|
5
|
-
from fastapi import FastAPI, Request
|
|
6
|
-
|
|
7
|
-
from lkr.logger import structured_logger as logger
|
|
8
|
-
from lkr.tools.classes import AttributeUpdaterResponse, UserAttributeUpdater
|
|
9
|
-
|
|
10
|
-
__all__ = ["group"]
|
|
11
|
-
|
|
12
|
-
group = typer.Typer()
|
|
13
|
-
|
|
14
|
-
if not logger:
|
|
15
|
-
raise Exception("Logger is not available")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@group.command()
|
|
19
|
-
def user_attribute_updater(
|
|
20
|
-
host: str = typer.Option(default="127.0.0.1", envvar="HOST"),
|
|
21
|
-
port: int = typer.Option(default=8080, envvar="PORT"),
|
|
22
|
-
):
|
|
23
|
-
api = FastAPI()
|
|
24
|
-
|
|
25
|
-
@api.post("/identity_token")
|
|
26
|
-
def identity_token(request: Request, body: UserAttributeUpdater):
|
|
27
|
-
try:
|
|
28
|
-
body.get_request_authorization_for_value(request.headers.items())
|
|
29
|
-
body.update_user_attribute_value()
|
|
30
|
-
raw_urls = os.getenv("LOOKER_WHITELISTED_BASE_URLS", "")
|
|
31
|
-
whitelisted_base_urls = (
|
|
32
|
-
[url.strip() for url in raw_urls.split(",") if url.strip()]
|
|
33
|
-
if raw_urls
|
|
34
|
-
else []
|
|
35
|
-
)
|
|
36
|
-
logger.debug(
|
|
37
|
-
"identify_token_user_attribute",
|
|
38
|
-
**body.model_dump(),
|
|
39
|
-
whitelisted_base_urls=whitelisted_base_urls,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
if body.base_url not in whitelisted_base_urls:
|
|
43
|
-
raise Exception(f"Base URL {body.base_url} not whitelisted")
|
|
44
|
-
|
|
45
|
-
return AttributeUpdaterResponse(
|
|
46
|
-
success=True, message="User attribute updated"
|
|
47
|
-
)
|
|
48
|
-
except Exception as e:
|
|
49
|
-
return AttributeUpdaterResponse(success=False, message=str(e))
|
|
50
|
-
|
|
51
|
-
@api.delete("/value")
|
|
52
|
-
def delete_user_attribute_value(request: Request, body: UserAttributeUpdater):
|
|
53
|
-
try:
|
|
54
|
-
body.delete_user_attribute_value()
|
|
55
|
-
logger.debug(
|
|
56
|
-
"user_attribute_delete",
|
|
57
|
-
**body.model_dump(),
|
|
58
|
-
)
|
|
59
|
-
return AttributeUpdaterResponse(
|
|
60
|
-
success=True, message="User attribute value deleted"
|
|
61
|
-
)
|
|
62
|
-
except Exception as e:
|
|
63
|
-
return AttributeUpdaterResponse(success=False, message=str(e))
|
|
64
|
-
|
|
65
|
-
@api.post("/value")
|
|
66
|
-
def update_user_attribute_value(request: Request, body: UserAttributeUpdater):
|
|
67
|
-
try:
|
|
68
|
-
body.update_user_attribute_value()
|
|
69
|
-
logger.debug(
|
|
70
|
-
"user_attribute_update",
|
|
71
|
-
**body.model_dump(),
|
|
72
|
-
)
|
|
73
|
-
return AttributeUpdaterResponse(
|
|
74
|
-
success=True, message="User attribute value updated"
|
|
75
|
-
)
|
|
76
|
-
except Exception as e:
|
|
77
|
-
return AttributeUpdaterResponse(success=False, message=str(e))
|
|
78
|
-
|
|
79
|
-
@api.get("")
|
|
80
|
-
def health():
|
|
81
|
-
return {"status": "ok"}
|
|
82
|
-
|
|
83
|
-
uvicorn.run(api, host=host, port=port)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if __name__ == "__main__":
|
|
87
|
-
group()
|
lkr_dev_cli-0.0.32/test.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|