isolinear 0.1.0__py3-none-any.whl
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.
- isolinear/__init__.py +0 -0
- isolinear/__main__.py +4 -0
- isolinear/app.py +71 -0
- isolinear/application/__init__.py +16 -0
- isolinear/application/onboarding.py +64 -0
- isolinear/application/read_model.py +73 -0
- isolinear/application/workspace.py +141 -0
- isolinear/domain/__init__.py +46 -0
- isolinear/domain/errors.py +12 -0
- isolinear/domain/host.py +15 -0
- isolinear/domain/models.py +102 -0
- isolinear/domain/permissions.py +58 -0
- isolinear/domain/ports.py +50 -0
- isolinear/domain/secret_store.py +30 -0
- isolinear/infrastructure/__init__.py +18 -0
- isolinear/infrastructure/config.py +13 -0
- isolinear/infrastructure/connector.py +113 -0
- isolinear/infrastructure/databricks.py +154 -0
- isolinear/infrastructure/profiles.py +66 -0
- isolinear/interface/__init__.py +0 -0
- isolinear/interface/modals.py +360 -0
- isolinear/interface/screens/__init__.py +0 -0
- isolinear/interface/screens/login.py +292 -0
- isolinear/interface/screens/main.py +591 -0
- isolinear/interface/theme.py +84 -0
- isolinear/interface/widgets.py +321 -0
- isolinear/styles.tcss +260 -0
- isolinear-0.1.0.dist-info/METADATA +163 -0
- isolinear-0.1.0.dist-info/RECORD +32 -0
- isolinear-0.1.0.dist-info/WHEEL +4 -0
- isolinear-0.1.0.dist-info/entry_points.txt +3 -0
- isolinear-0.1.0.dist-info/licenses/LICENSE +21 -0
isolinear/__init__.py
ADDED
|
File without changes
|
isolinear/__main__.py
ADDED
isolinear/app.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Isolinear — a terminal Databricks secret manager.
|
|
2
|
+
|
|
3
|
+
The App is the composition root: it builds the infrastructure adapters, wires
|
|
4
|
+
them into the `OnboardingService`, installs the theme, and hands off to
|
|
5
|
+
`MainScreen`. Domain logic lives in `isolinear.domain`, use-cases in
|
|
6
|
+
`isolinear.application`, adapters in `isolinear.infrastructure`; UI in
|
|
7
|
+
`isolinear.interface`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from textual.app import App
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
|
|
15
|
+
from .application import OnboardingService, WorkspaceService
|
|
16
|
+
from .infrastructure import DatabricksCfgProfileStore, DatabricksConnector
|
|
17
|
+
from .interface.screens.main import MainScreen
|
|
18
|
+
from .interface.theme import ISOLINEAR_THEMES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IsolinearApp(App[None]):
|
|
22
|
+
CSS_PATH = "styles.tcss"
|
|
23
|
+
TITLE = "Isolinear"
|
|
24
|
+
BINDINGS = [Binding("q", "quit", "Quit")]
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
onboarding: OnboardingService | None = None,
|
|
29
|
+
session: WorkspaceService | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__()
|
|
32
|
+
self._onboarding = onboarding
|
|
33
|
+
self._initial_session = session
|
|
34
|
+
|
|
35
|
+
def on_mount(self) -> None:
|
|
36
|
+
for theme in ISOLINEAR_THEMES:
|
|
37
|
+
self.register_theme(theme)
|
|
38
|
+
self.theme = "isolinear"
|
|
39
|
+
onboarding = self._onboarding or OnboardingService(
|
|
40
|
+
DatabricksConnector(), DatabricksCfgProfileStore()
|
|
41
|
+
)
|
|
42
|
+
self.push_screen(MainScreen(onboarding, self._initial_session))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_USAGE = """\
|
|
46
|
+
isolinear — a keyboard-driven terminal UI for managing Databricks secrets.
|
|
47
|
+
|
|
48
|
+
usage: isolinear [--version] [--help]
|
|
49
|
+
|
|
50
|
+
Run with no arguments to launch the TUI. Inside: ? for help, ctrl+p for the
|
|
51
|
+
command palette, q to quit.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
args = sys.argv[1:]
|
|
59
|
+
if {"-V", "--version"} & set(args):
|
|
60
|
+
from importlib.metadata import version
|
|
61
|
+
|
|
62
|
+
print(f"isolinear {version('isolinear')}")
|
|
63
|
+
return
|
|
64
|
+
if {"-h", "--help"} & set(args):
|
|
65
|
+
print(_USAGE, end="")
|
|
66
|
+
return
|
|
67
|
+
IsolinearApp().run()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Application layer — use-cases that orchestrate the domain.
|
|
2
|
+
|
|
3
|
+
Depends only on `domain`; never on infrastructure or the UI. The read model
|
|
4
|
+
(`WorkspaceCache`) is pure in-memory app state, so it lives here too.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .onboarding import Connection, OnboardingService
|
|
8
|
+
from .read_model import WorkspaceCache
|
|
9
|
+
from .workspace import WorkspaceService
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Connection",
|
|
13
|
+
"OnboardingService",
|
|
14
|
+
"WorkspaceCache",
|
|
15
|
+
"WorkspaceService",
|
|
16
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""OnboardingService — connection/login use-cases.
|
|
2
|
+
|
|
3
|
+
Depends only on domain ports (`WorkspaceConnector`, `ProfileStore`); the
|
|
4
|
+
composition root injects concrete infrastructure adapters. Produces ready-to-use
|
|
5
|
+
`WorkspaceService` instances so the UI never touches the store or the SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ..domain import (
|
|
13
|
+
AccountSession,
|
|
14
|
+
AccountWorkspace,
|
|
15
|
+
ProfileStore,
|
|
16
|
+
Workspace,
|
|
17
|
+
WorkspaceConnector,
|
|
18
|
+
)
|
|
19
|
+
from .workspace import WorkspaceService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Connection:
|
|
24
|
+
"""A live workspace session plus what's needed to persist it as a profile."""
|
|
25
|
+
|
|
26
|
+
service: WorkspaceService
|
|
27
|
+
host: str = ""
|
|
28
|
+
account_id: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OnboardingService:
|
|
32
|
+
def __init__(self, connector: WorkspaceConnector, profiles: ProfileStore) -> None:
|
|
33
|
+
self._connector = connector
|
|
34
|
+
self._profiles = profiles
|
|
35
|
+
|
|
36
|
+
# -- saved profiles -------------------------------------------------
|
|
37
|
+
def saved_workspaces(self) -> list[Workspace]:
|
|
38
|
+
return self._profiles.discover()
|
|
39
|
+
|
|
40
|
+
def save_profile(self, name: str, host: str, account_id: str | None = None) -> None:
|
|
41
|
+
self._profiles.save(name, host, account_id)
|
|
42
|
+
|
|
43
|
+
# -- connection use-cases -------------------------------------------
|
|
44
|
+
def connect_profile(self, profile: str) -> Connection:
|
|
45
|
+
c = self._connector.connect_profile(profile)
|
|
46
|
+
return Connection(WorkspaceService(c.store, c.label))
|
|
47
|
+
|
|
48
|
+
def connect_url(self, host: str) -> Connection:
|
|
49
|
+
c = self._connector.connect_url(host)
|
|
50
|
+
return Connection(WorkspaceService(c.store, c.label), host=c.host)
|
|
51
|
+
|
|
52
|
+
def discover_account(self, cloud_key: str, account_id: str) -> AccountSession:
|
|
53
|
+
return self._connector.discover_account(cloud_key, account_id)
|
|
54
|
+
|
|
55
|
+
def connect_account_workspace(
|
|
56
|
+
self,
|
|
57
|
+
session: AccountSession,
|
|
58
|
+
ws: AccountWorkspace,
|
|
59
|
+
account_id: str | None = None,
|
|
60
|
+
) -> Connection:
|
|
61
|
+
c = session.connect(ws)
|
|
62
|
+
return Connection(
|
|
63
|
+
WorkspaceService(c.store, c.label), host=c.host, account_id=account_id
|
|
64
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""WorkspaceCache — the in-memory read model the UI renders from.
|
|
2
|
+
|
|
3
|
+
A projection that the application service warms up front (US-14) and keeps in
|
|
4
|
+
sync on writes. Pure data + bookkeeping; it holds no business rules (those live
|
|
5
|
+
in the domain) and does no I/O.
|
|
6
|
+
|
|
7
|
+
Strategy (US-14/16):
|
|
8
|
+
* On connect the service warms scopes -> secret metadata -> ACLs in the
|
|
9
|
+
background.
|
|
10
|
+
* Secret *values* are NOT bulk-loaded; they are fetched lazily on reveal and
|
|
11
|
+
cached thereafter, so sensitive material isn't pulled into memory needlessly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from ..domain import Acl, Identity, Scope, Secret
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class WorkspaceCache:
|
|
23
|
+
label: str
|
|
24
|
+
identity: Identity = field(default_factory=Identity)
|
|
25
|
+
|
|
26
|
+
scopes: list[Scope] = field(default_factory=list)
|
|
27
|
+
secrets: dict[str, list[Secret]] = field(default_factory=dict)
|
|
28
|
+
acls: dict[str, list[Acl]] = field(default_factory=dict)
|
|
29
|
+
values: dict[tuple[str, str], str] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
scopes_loaded: bool = False
|
|
32
|
+
warm_error: str = ""
|
|
33
|
+
|
|
34
|
+
# -- lookups ------------------------------------------------------------
|
|
35
|
+
def secrets_for(self, scope: str) -> list[Secret]:
|
|
36
|
+
return self.secrets.get(scope, [])
|
|
37
|
+
|
|
38
|
+
def acls_for(self, scope: str) -> list[Acl]:
|
|
39
|
+
return self.acls.get(scope, [])
|
|
40
|
+
|
|
41
|
+
def cached_value(self, scope: str, key: str) -> str | None:
|
|
42
|
+
return self.values.get((scope, key))
|
|
43
|
+
|
|
44
|
+
def set_value(self, scope: str, key: str, value: str) -> None:
|
|
45
|
+
self.values[(scope, key)] = value
|
|
46
|
+
|
|
47
|
+
# -- mutation keeping the read model + UI consistent --------------------
|
|
48
|
+
def upsert_secret(self, secret: Secret) -> None:
|
|
49
|
+
rows = self.secrets.setdefault(secret.scope, [])
|
|
50
|
+
for i, existing in enumerate(rows):
|
|
51
|
+
if existing.key == secret.key:
|
|
52
|
+
rows[i] = secret
|
|
53
|
+
break
|
|
54
|
+
else:
|
|
55
|
+
rows.append(secret)
|
|
56
|
+
rows.sort(key=lambda s: s.key.lower())
|
|
57
|
+
|
|
58
|
+
def remove_secret(self, scope: str, key: str) -> None:
|
|
59
|
+
self.secrets[scope] = [s for s in self.secrets.get(scope, []) if s.key != key]
|
|
60
|
+
self.values.pop((scope, key), None)
|
|
61
|
+
|
|
62
|
+
def add_scope(self, scope: Scope) -> None:
|
|
63
|
+
if not any(s.name == scope.name for s in self.scopes):
|
|
64
|
+
self.scopes.append(scope)
|
|
65
|
+
self.scopes.sort(key=lambda s: s.name.lower())
|
|
66
|
+
self.secrets.setdefault(scope.name, [])
|
|
67
|
+
self.acls.setdefault(scope.name, [])
|
|
68
|
+
|
|
69
|
+
def remove_scope(self, name: str) -> None:
|
|
70
|
+
self.scopes = [s for s in self.scopes if s.name != name]
|
|
71
|
+
self.secrets.pop(name, None)
|
|
72
|
+
self.acls.pop(name, None)
|
|
73
|
+
self.values = {k: v for k, v in self.values.items() if k[0] != name}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""WorkspaceService — the application service / use-case layer.
|
|
2
|
+
|
|
3
|
+
Orchestrates a `SecretStore` (domain port) and the read model, exposing every
|
|
4
|
+
operation the UI needs as a plain, synchronous method. All store/cache
|
|
5
|
+
coordination lives here, so:
|
|
6
|
+
|
|
7
|
+
* the UI just calls a method and renders the result (no business logic), and
|
|
8
|
+
* the whole layer is unit-testable with a fake store and no event loop.
|
|
9
|
+
|
|
10
|
+
The UI runs these (blocking) methods in worker threads.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
|
|
17
|
+
from ..domain import (
|
|
18
|
+
Acl,
|
|
19
|
+
AuthSummary,
|
|
20
|
+
Identity,
|
|
21
|
+
Scope,
|
|
22
|
+
Secret,
|
|
23
|
+
SecretStore,
|
|
24
|
+
StoreError,
|
|
25
|
+
authorization_summary,
|
|
26
|
+
)
|
|
27
|
+
from .read_model import WorkspaceCache
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WorkspaceService:
|
|
31
|
+
def __init__(
|
|
32
|
+
self, store: SecretStore, label: str, cache: WorkspaceCache | None = None
|
|
33
|
+
) -> None:
|
|
34
|
+
self._store = store
|
|
35
|
+
# The read model is injectable (decoupled), defaulting to a fresh one.
|
|
36
|
+
self.cache = cache or WorkspaceCache(label=label)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def label(self) -> str:
|
|
40
|
+
return self.cache.label
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def identity(self) -> Identity:
|
|
44
|
+
return self.cache.identity
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def scopes(self) -> list[Scope]:
|
|
48
|
+
return self.cache.scopes
|
|
49
|
+
|
|
50
|
+
# -- connection / warming -------------------------------------------
|
|
51
|
+
def authenticate(self) -> Identity:
|
|
52
|
+
identity = self._store.whoami()
|
|
53
|
+
self.cache.identity = identity
|
|
54
|
+
return identity
|
|
55
|
+
|
|
56
|
+
def load_scopes(self) -> list[Scope]:
|
|
57
|
+
scopes = self._store.list_scopes()
|
|
58
|
+
self.cache.scopes = scopes
|
|
59
|
+
self.cache.scopes_loaded = True
|
|
60
|
+
return scopes
|
|
61
|
+
|
|
62
|
+
def warm_scope(self, scope: str) -> None:
|
|
63
|
+
"""Pull secret metadata + ACLs for one scope into the read model."""
|
|
64
|
+
try:
|
|
65
|
+
self.cache.secrets[scope] = self._store.list_secrets(scope)
|
|
66
|
+
self.cache.acls[scope] = self._store.list_acls(scope)
|
|
67
|
+
except StoreError:
|
|
68
|
+
self.cache.secrets.setdefault(scope, [])
|
|
69
|
+
self.cache.acls.setdefault(scope, [])
|
|
70
|
+
|
|
71
|
+
def warm_all(self) -> Iterator[tuple[int, int, Scope]]:
|
|
72
|
+
"""Warm every scope, yielding (index, total, scope) for progress."""
|
|
73
|
+
total = len(self.cache.scopes)
|
|
74
|
+
for i, scope in enumerate(self.cache.scopes, 1):
|
|
75
|
+
self.warm_scope(scope.name)
|
|
76
|
+
yield i, total, scope
|
|
77
|
+
|
|
78
|
+
# -- reads ----------------------------------------------------------
|
|
79
|
+
def secrets_for(self, scope: str) -> list[Secret]:
|
|
80
|
+
return self.cache.secrets_for(scope)
|
|
81
|
+
|
|
82
|
+
def acls_for(self, scope: str) -> list[Acl]:
|
|
83
|
+
return self.cache.acls_for(scope)
|
|
84
|
+
|
|
85
|
+
def scope(self, name: str) -> Scope | None:
|
|
86
|
+
return next((s for s in self.cache.scopes if s.name == name), None)
|
|
87
|
+
|
|
88
|
+
def secret(self, scope: str, key: str) -> Secret | None:
|
|
89
|
+
return next((s for s in self.cache.secrets_for(scope) if s.key == key), None)
|
|
90
|
+
|
|
91
|
+
def cached_value(self, scope: str, key: str) -> str | None:
|
|
92
|
+
return self.cache.cached_value(scope, key)
|
|
93
|
+
|
|
94
|
+
def reveal(self, scope: str, key: str) -> str:
|
|
95
|
+
"""Return the secret value, fetching+caching it on first access."""
|
|
96
|
+
cached = self.cache.cached_value(scope, key)
|
|
97
|
+
if cached is not None:
|
|
98
|
+
return cached
|
|
99
|
+
value = self._store.get_secret_value(scope, key)
|
|
100
|
+
self.cache.set_value(scope, key, value)
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
# -- mutations ------------------------------------------------------
|
|
104
|
+
def put_secret(self, scope: str, key: str, value: str) -> None:
|
|
105
|
+
self._store.put_secret(scope, key, value)
|
|
106
|
+
try: # refresh metadata so the timestamp is accurate
|
|
107
|
+
self.cache.secrets[scope] = self._store.list_secrets(scope)
|
|
108
|
+
except StoreError:
|
|
109
|
+
self.cache.upsert_secret(Secret(scope=scope, key=key))
|
|
110
|
+
self.cache.set_value(scope, key, value)
|
|
111
|
+
|
|
112
|
+
def delete_secret(self, scope: str, key: str) -> None:
|
|
113
|
+
self._store.delete_secret(scope, key)
|
|
114
|
+
self.cache.remove_secret(scope, key)
|
|
115
|
+
|
|
116
|
+
def create_scope(self, name: str) -> None:
|
|
117
|
+
self._store.create_scope(name)
|
|
118
|
+
self.cache.add_scope(Scope(name=name))
|
|
119
|
+
|
|
120
|
+
def delete_scope(self, name: str) -> None:
|
|
121
|
+
self._store.delete_scope(name)
|
|
122
|
+
self.cache.remove_scope(name)
|
|
123
|
+
|
|
124
|
+
def refresh_scope(self, scope: str) -> None:
|
|
125
|
+
self.warm_scope(scope)
|
|
126
|
+
|
|
127
|
+
# -- scope permissions / ACLs (US-11 update, US-12) -----------------
|
|
128
|
+
def set_acl(self, scope: str, principal: str, permission: str) -> None:
|
|
129
|
+
"""Grant or change a principal's permission (put_acl is an upsert)."""
|
|
130
|
+
self._store.put_acl(scope, principal, permission)
|
|
131
|
+
self.cache.acls[scope] = self._store.list_acls(scope)
|
|
132
|
+
|
|
133
|
+
def remove_acl(self, scope: str, principal: str) -> None:
|
|
134
|
+
self._store.delete_acl(scope, principal)
|
|
135
|
+
self.cache.acls[scope] = self._store.list_acls(scope)
|
|
136
|
+
|
|
137
|
+
# -- authorization overview ----------------------------------------
|
|
138
|
+
def auth_summary(self) -> list[AuthSummary]:
|
|
139
|
+
return authorization_summary(
|
|
140
|
+
self.cache.identity, self.cache.scopes, self.cache.acls
|
|
141
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Domain layer — the model, the rules, and the ports. Pure: no Textual, no SDK,
|
|
2
|
+
no asyncio.
|
|
3
|
+
|
|
4
|
+
Everything here is exercisable in a plain unit test. Infrastructure implements
|
|
5
|
+
the ports defined here; the application orchestrates these pieces.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .errors import AuthError, StoreError
|
|
9
|
+
from .host import normalize_host
|
|
10
|
+
from .models import (
|
|
11
|
+
CLOUDS,
|
|
12
|
+
AccountWorkspace,
|
|
13
|
+
Acl,
|
|
14
|
+
Cloud,
|
|
15
|
+
Identity,
|
|
16
|
+
Scope,
|
|
17
|
+
Secret,
|
|
18
|
+
Workspace,
|
|
19
|
+
cloud_by_key,
|
|
20
|
+
)
|
|
21
|
+
from .permissions import AuthSummary, authorization_summary, perm_rank
|
|
22
|
+
from .ports import AccountSession, Connected, ProfileStore, WorkspaceConnector
|
|
23
|
+
from .secret_store import SecretStore
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CLOUDS",
|
|
27
|
+
"AccountSession",
|
|
28
|
+
"AccountWorkspace",
|
|
29
|
+
"Acl",
|
|
30
|
+
"AuthError",
|
|
31
|
+
"AuthSummary",
|
|
32
|
+
"Cloud",
|
|
33
|
+
"Connected",
|
|
34
|
+
"Identity",
|
|
35
|
+
"ProfileStore",
|
|
36
|
+
"Scope",
|
|
37
|
+
"Secret",
|
|
38
|
+
"SecretStore",
|
|
39
|
+
"StoreError",
|
|
40
|
+
"Workspace",
|
|
41
|
+
"WorkspaceConnector",
|
|
42
|
+
"authorization_summary",
|
|
43
|
+
"cloud_by_key",
|
|
44
|
+
"normalize_host",
|
|
45
|
+
"perm_rank",
|
|
46
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Domain-level error types — abstractions the application catches without
|
|
2
|
+
knowing (or caring) that the backend is Databricks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StoreError(Exception):
|
|
8
|
+
"""A secret-store operation failed; carries a UI-friendly message."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthError(Exception):
|
|
12
|
+
"""Login / workspace-discovery failure; carries a UI-friendly message."""
|
isolinear/domain/host.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Host — a small domain rule about workspace URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import AuthError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_host(host: str) -> str:
|
|
9
|
+
"""Canonicalize a workspace URL; reject an empty one."""
|
|
10
|
+
host = (host or "").strip()
|
|
11
|
+
if not host:
|
|
12
|
+
raise AuthError("Workspace URL is required.")
|
|
13
|
+
if not host.startswith(("http://", "https://")):
|
|
14
|
+
host = "https://" + host
|
|
15
|
+
return host.rstrip("/")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Domain model — the ubiquitous language of Isolinear as plain value objects.
|
|
2
|
+
|
|
3
|
+
No Textual, no Databricks SDK, no I/O. These are the nouns the whole app speaks:
|
|
4
|
+
workspaces, scopes, secrets, ACLs, identity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Workspace:
|
|
15
|
+
"""A connection target — one profile from ~/.databrickscfg."""
|
|
16
|
+
|
|
17
|
+
profile: str
|
|
18
|
+
host: str
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def label(self) -> str:
|
|
22
|
+
host = self.host.replace("https://", "").replace("http://", "").rstrip("/")
|
|
23
|
+
return f"{self.profile} · {host}" if host else self.profile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Cloud:
|
|
28
|
+
key: str # aws | azure | gcp
|
|
29
|
+
label: str
|
|
30
|
+
account_host: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CLOUDS: list[Cloud] = [
|
|
34
|
+
Cloud("aws", "AWS", "https://accounts.cloud.databricks.com"),
|
|
35
|
+
Cloud("azure", "Azure", "https://accounts.azuredatabricks.net"),
|
|
36
|
+
Cloud("gcp", "GCP", "https://accounts.gcp.databricks.com"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cloud_by_key(key: str) -> Cloud:
|
|
41
|
+
return next(c for c in CLOUDS if c.key == key)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AccountWorkspace:
|
|
46
|
+
"""A workspace discovered via the account-level API (US-1, discovery)."""
|
|
47
|
+
|
|
48
|
+
workspace_id: int
|
|
49
|
+
name: str
|
|
50
|
+
deployment_name: str = ""
|
|
51
|
+
status: str = ""
|
|
52
|
+
cloud: str = ""
|
|
53
|
+
raw: object = None # original SDK provisioning.Workspace, for get_workspace_client
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def label(self) -> str:
|
|
57
|
+
status = f" · {self.status}" if self.status else ""
|
|
58
|
+
return f"{self.name} · id {self.workspace_id}{status}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Scope:
|
|
63
|
+
name: str
|
|
64
|
+
backend_type: str = "DATABRICKS"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_keyvault(self) -> bool:
|
|
68
|
+
return self.backend_type == "AZURE_KEYVAULT"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def icon(self) -> str:
|
|
72
|
+
return "☁" if self.is_keyvault else "🔒"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Secret:
|
|
77
|
+
scope: str
|
|
78
|
+
key: str
|
|
79
|
+
last_updated_ms: int | None = None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def last_updated(self) -> str:
|
|
83
|
+
if not self.last_updated_ms:
|
|
84
|
+
return "—"
|
|
85
|
+
dt = datetime.fromtimestamp(self.last_updated_ms / 1000, tz=UTC)
|
|
86
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Acl:
|
|
91
|
+
principal: str
|
|
92
|
+
permission: str # READ | WRITE | MANAGE
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Identity:
|
|
97
|
+
"""Who we are authenticated as in a workspace."""
|
|
98
|
+
|
|
99
|
+
user_name: str = ""
|
|
100
|
+
display_name: str = ""
|
|
101
|
+
authenticated: bool = False
|
|
102
|
+
error: str = ""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Authorization rules — a domain service.
|
|
2
|
+
|
|
3
|
+
The "what can this principal do" logic that used to live on the cache. It's a
|
|
4
|
+
business rule about scopes + ACLs + identity, so it belongs in the domain.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .models import Acl, Identity, Scope
|
|
13
|
+
|
|
14
|
+
_PERM_RANK = {"READ": 1, "WRITE": 2, "MANAGE": 3}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def perm_rank(permission: str) -> int:
|
|
18
|
+
return _PERM_RANK.get(permission, 0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AuthSummary:
|
|
23
|
+
"""Per-scope authorization picture for the authorization overview screen."""
|
|
24
|
+
|
|
25
|
+
scope: str
|
|
26
|
+
effective: str = "—" # current user's effective permission
|
|
27
|
+
acl_count: int = 0
|
|
28
|
+
can_write: bool = False
|
|
29
|
+
can_manage: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def authorization_summary(
|
|
33
|
+
identity: Identity,
|
|
34
|
+
scopes: list[Scope],
|
|
35
|
+
acls_by_scope: Mapping[str, list[Acl]],
|
|
36
|
+
) -> list[AuthSummary]:
|
|
37
|
+
"""Compute the current user's effective permission on each scope (US-13)."""
|
|
38
|
+
me = identity.user_name
|
|
39
|
+
summaries: list[AuthSummary] = []
|
|
40
|
+
for scope in scopes:
|
|
41
|
+
acls = acls_by_scope.get(scope.name, [])
|
|
42
|
+
effective = "—"
|
|
43
|
+
best = 0
|
|
44
|
+
for acl in acls:
|
|
45
|
+
is_mine = acl.principal in (me, "users", "admins")
|
|
46
|
+
if is_mine and perm_rank(acl.permission) > best:
|
|
47
|
+
best = perm_rank(acl.permission)
|
|
48
|
+
effective = acl.permission
|
|
49
|
+
summaries.append(
|
|
50
|
+
AuthSummary(
|
|
51
|
+
scope=scope.name,
|
|
52
|
+
effective=effective,
|
|
53
|
+
acl_count=len(acls),
|
|
54
|
+
can_write=best >= perm_rank("WRITE"),
|
|
55
|
+
can_manage=best >= perm_rank("MANAGE"),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return summaries
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Ports — the domain's outbound contracts for all I/O.
|
|
2
|
+
|
|
3
|
+
Infrastructure implements these; the application depends only on them. Together
|
|
4
|
+
with `SecretStore` (in secret_store.py) they are every door out of the domain.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from .models import AccountWorkspace, Workspace
|
|
13
|
+
from .secret_store import SecretStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Connected:
|
|
18
|
+
"""The result of establishing a connection: a store plus how to label and
|
|
19
|
+
(optionally) persist it."""
|
|
20
|
+
|
|
21
|
+
store: SecretStore
|
|
22
|
+
label: str
|
|
23
|
+
host: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class AccountSession(Protocol):
|
|
28
|
+
"""A live account-discovery session — lists workspaces and connects to one,
|
|
29
|
+
reusing a single authenticated session."""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def workspaces(self) -> list[AccountWorkspace]: ...
|
|
33
|
+
def connect(self, ws: AccountWorkspace) -> Connected: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class WorkspaceConnector(Protocol):
|
|
38
|
+
"""Establishes connections to a secret backend (login / discovery)."""
|
|
39
|
+
|
|
40
|
+
def connect_profile(self, profile: str) -> Connected: ...
|
|
41
|
+
def connect_url(self, host: str) -> Connected: ...
|
|
42
|
+
def discover_account(self, cloud_key: str, account_id: str) -> AccountSession: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class ProfileStore(Protocol):
|
|
47
|
+
"""Reads and writes saved connection profiles."""
|
|
48
|
+
|
|
49
|
+
def discover(self) -> list[Workspace]: ...
|
|
50
|
+
def save(self, name: str, host: str, account_id: str | None = None) -> None: ...
|