alab-cli 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.
alab/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """ALab local experiment workbench."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
alab/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
alab/auth.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import secrets
5
+ import stat
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .db import canonical_json, contract_json_obj, one
12
+ from .errors import AlabError
13
+ from .ids import new_id, random_suffix
14
+ from .timeutil import utc_now
15
+
16
+ ROOT_PREFIX = "alab_root_v1_"
17
+ ADMIN_PREFIX = "alab_admin_v1_"
18
+ TOKEN_PREFIX = "alab_token_v1_"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Actor:
23
+ actor_type: str
24
+ credential_id: str | None = None
25
+ project_id: str | None = None
26
+ exp_id: str | None = None
27
+ token_mode: str | None = None
28
+
29
+
30
+ def _invalid_credential() -> AlabError:
31
+ return AlabError("AUTH_DENIED", "invalid credential")
32
+
33
+
34
+ def _secret() -> str:
35
+ return secrets.token_hex(32)
36
+
37
+
38
+ def _verifier(secret: str, salt: bytes) -> bytes:
39
+ return hmac.new(salt, secret.encode("utf-8"), sha256).digest()
40
+
41
+
42
+ def credential_metadata_obj(
43
+ text: str,
44
+ *,
45
+ credential_type: str,
46
+ token_mode: str | None = None,
47
+ registered_path_hash: str | None = None,
48
+ ) -> dict[str, Any]:
49
+ allowed_keys = {"schema_version", "display_label"}
50
+ required_keys: set[str] = set()
51
+ if credential_type == "admin":
52
+ allowed_keys.add("role")
53
+ required_keys.add("role")
54
+ elif credential_type == "token":
55
+ allowed_keys.update({"token_mode", "created_for_path_hash"})
56
+ required_keys.update({"token_mode", "created_for_path_hash"})
57
+ elif credential_type != "root":
58
+ raise AlabError("STORAGE_ERROR", f"unknown credential_type for metadata: {credential_type}")
59
+
60
+ value = contract_json_obj(
61
+ text,
62
+ label="credentials.metadata_json",
63
+ allowed_keys=allowed_keys,
64
+ required_keys=required_keys,
65
+ )
66
+ display_label = value.get("display_label")
67
+ if display_label is not None and not isinstance(display_label, str):
68
+ raise AlabError("STORAGE_ERROR", "credentials.metadata_json display_label must be a string")
69
+ if credential_type == "admin" and value.get("role") != "admin":
70
+ raise AlabError("STORAGE_ERROR", "credentials.metadata_json role must be admin")
71
+ if credential_type == "token":
72
+ if value.get("token_mode") != token_mode:
73
+ raise AlabError("STORAGE_ERROR", "credentials.metadata_json token_mode does not match credential row")
74
+ if value.get("created_for_path_hash") != registered_path_hash:
75
+ raise AlabError("STORAGE_ERROR", "credentials.metadata_json created_for_path_hash does not match credential row")
76
+ return value
77
+
78
+
79
+ def _default_credential_metadata(
80
+ *,
81
+ credential_type: str,
82
+ token_mode: str | None,
83
+ registered_path_hash: str | None,
84
+ ) -> dict[str, Any]:
85
+ if credential_type == "root":
86
+ return {"schema_version": 1}
87
+ if credential_type == "admin":
88
+ return {"schema_version": 1, "role": "admin"}
89
+ if credential_type == "token":
90
+ return {
91
+ "schema_version": 1,
92
+ "token_mode": token_mode,
93
+ "created_for_path_hash": registered_path_hash,
94
+ }
95
+ raise AlabError("STORAGE_ERROR", f"unknown credential_type for metadata: {credential_type}")
96
+
97
+
98
+ def create_credential(
99
+ conn,
100
+ *,
101
+ credential_type: str,
102
+ project_id: str | None = None,
103
+ exp_id: str | None = None,
104
+ token_mode: str | None = None,
105
+ registered_path_hash: str | None = None,
106
+ metadata: dict | None = None,
107
+ ) -> tuple[str, str]:
108
+ credential_id = new_id("cred", credential_type)
109
+ secret = _secret()
110
+ prefix = {"root": ROOT_PREFIX, "admin": ADMIN_PREFIX, "token": TOKEN_PREFIX}[credential_type]
111
+ raw = f"{prefix}{credential_id}_{secret}"
112
+ salt = secrets.token_bytes(32)
113
+ now = utc_now()
114
+ metadata_payload = metadata or _default_credential_metadata(
115
+ credential_type=credential_type,
116
+ token_mode=token_mode,
117
+ registered_path_hash=registered_path_hash,
118
+ )
119
+ metadata_json = canonical_json(
120
+ credential_metadata_obj(
121
+ canonical_json(metadata_payload),
122
+ credential_type=credential_type,
123
+ token_mode=token_mode,
124
+ registered_path_hash=registered_path_hash,
125
+ )
126
+ )
127
+ conn.execute(
128
+ """
129
+ INSERT INTO credentials(
130
+ credential_id, credential_type, project_id, exp_id, token_mode, registered_path_hash,
131
+ status, salt, verifier_hash, created_at, revoked_at, metadata_json
132
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL, ?)
133
+ """,
134
+ (
135
+ credential_id,
136
+ credential_type,
137
+ project_id,
138
+ exp_id,
139
+ token_mode,
140
+ registered_path_hash,
141
+ salt,
142
+ _verifier(secret, salt),
143
+ now,
144
+ metadata_json,
145
+ ),
146
+ )
147
+ return credential_id, raw
148
+
149
+
150
+ def parse_raw_credential(raw: str) -> tuple[str, str, str]:
151
+ for kind, prefix in [("root", ROOT_PREFIX), ("admin", ADMIN_PREFIX), ("token", TOKEN_PREFIX)]:
152
+ if raw.startswith(prefix):
153
+ rest = raw[len(prefix) :]
154
+ credential_id, sep, secret = rest.rpartition("_")
155
+ if not sep or not credential_id or not secret:
156
+ raise _invalid_credential()
157
+ return kind, credential_id, secret
158
+ raise _invalid_credential()
159
+
160
+
161
+ def verify_raw_credential(
162
+ conn,
163
+ raw: str,
164
+ *,
165
+ required: str | tuple[str, ...] | None = None,
166
+ project_id: str | None = None,
167
+ exp_id: str | None = None,
168
+ token_mode: str | None = None,
169
+ path_hash: str | None = None,
170
+ ) -> Actor:
171
+ kind, credential_id, secret = parse_raw_credential(raw.strip())
172
+ row = one(conn, "SELECT * FROM credentials WHERE credential_id = ?", (credential_id,))
173
+ if row is None:
174
+ raise _invalid_credential()
175
+ if row["credential_type"] != kind or row["status"] != "active":
176
+ raise _invalid_credential()
177
+ allowed = (required,) if isinstance(required, str) else required
178
+ if allowed and kind not in allowed:
179
+ raise _invalid_credential()
180
+ if not hmac.compare_digest(bytes(row["verifier_hash"]), _verifier(secret, bytes(row["salt"]))):
181
+ raise _invalid_credential()
182
+ credential_metadata_obj(
183
+ row["metadata_json"],
184
+ credential_type=row["credential_type"],
185
+ token_mode=row["token_mode"],
186
+ registered_path_hash=row["registered_path_hash"],
187
+ )
188
+ if project_id and row["project_id"] and row["project_id"] != project_id:
189
+ raise _invalid_credential()
190
+ if kind == "admin" and project_id and row["project_id"] != project_id:
191
+ raise _invalid_credential()
192
+ if kind == "token":
193
+ if project_id and row["project_id"] != project_id:
194
+ raise _invalid_credential()
195
+ if exp_id and row["exp_id"] != exp_id:
196
+ raise _invalid_credential()
197
+ if token_mode and row["token_mode"] != token_mode:
198
+ raise _invalid_credential()
199
+ if path_hash and row["registered_path_hash"] != path_hash:
200
+ raise _invalid_credential()
201
+ return Actor(
202
+ actor_type=kind,
203
+ credential_id=row["credential_id"],
204
+ project_id=row["project_id"],
205
+ exp_id=row["exp_id"],
206
+ token_mode=row["token_mode"],
207
+ )
208
+
209
+
210
+ def read_token(path: Path) -> str:
211
+ token_path = path / ".alab" / "token"
212
+ try:
213
+ raw = token_path.read_text(encoding="utf-8")
214
+ except FileNotFoundError as exc:
215
+ raise AlabError("AUTH_REQUIRED", "token file not found") from exc
216
+ return raw.rstrip("\n")
217
+
218
+
219
+ def token_permission_warning(path: Path) -> str | None:
220
+ token_path = path / ".alab" / "token"
221
+ try:
222
+ mode = stat.S_IMODE(token_path.stat().st_mode)
223
+ except OSError:
224
+ return None
225
+ if mode & 0o077:
226
+ return "TOKEN_FILE_PERMISSIONS"
227
+ return None
228
+
229
+
230
+ def write_token(path: Path, raw_token: str) -> None:
231
+ token_dir = path / ".alab"
232
+ token_dir.mkdir(parents=True, exist_ok=True)
233
+ token_path = token_dir / "token"
234
+ token_path.write_text(raw_token + "\n", encoding="utf-8")
235
+ try:
236
+ token_path.chmod(0o600)
237
+ except OSError:
238
+ pass
239
+
240
+
241
+ def new_home_id() -> str:
242
+ return f"home-{random_suffix()}"