cfgit 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.
cfg/core/authz.py ADDED
@@ -0,0 +1,58 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """DB-neutral mutation authorization for cfgit."""
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+
7
+ from cfg.core.config import EnvConfig, PermissionConfig
8
+ from cfg.core.identity import Identity, self_asserted_identity
9
+
10
+
11
+ class PermissionDenied(Exception):
12
+ """The resolved author is not allowed to perform this mutation."""
13
+
14
+
15
+ def authorize_mutation(
16
+ env: EnvConfig,
17
+ *,
18
+ action: str,
19
+ identity: Identity | None = None,
20
+ author: str | None = None,
21
+ ) -> None:
22
+ resolved = identity or self_asserted_identity(author or "", cfg=env.identity)
23
+ if env.identity.mode in {"authenticated", "enforced"} and not resolved.authenticated:
24
+ raise PermissionDenied(
25
+ f"{resolved.author} cannot run {action} on {env.name}: "
26
+ f"{env.identity.mode} identity required"
27
+ )
28
+
29
+ role = permission_role(env.permissions, resolved.author)
30
+ admin_only = _matches(action, env.permissions.admin_actions)
31
+
32
+ if admin_only and role != "admin":
33
+ raise PermissionDenied(
34
+ f"{resolved.author} cannot run {action} on {env.name}: admin permission required"
35
+ )
36
+
37
+ if env.permissions.mode == "restricted" and role not in {"admin", "writer"}:
38
+ raise PermissionDenied(
39
+ f"{resolved.author} cannot run {action} on {env.name}: writer permission required"
40
+ )
41
+
42
+
43
+ def permission_role(policy: PermissionConfig, author: str) -> str:
44
+ if _matches(author, policy.admins):
45
+ return "admin"
46
+ if _matches(author, policy.writers):
47
+ return "writer"
48
+ return "open" if policy.mode == "open" else "none"
49
+
50
+
51
+ def _matches(value: str, patterns: tuple[str, ...]) -> bool:
52
+ raw = value.strip()
53
+ lowered = raw.lower()
54
+ for pattern in patterns:
55
+ candidate = pattern.strip()
56
+ if fnmatch.fnmatchcase(raw, candidate) or fnmatch.fnmatchcase(lowered, candidate.lower()):
57
+ return True
58
+ return False
cfg/core/config.py ADDED
@@ -0,0 +1,324 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Project configuration loader for cfgit.
3
+
4
+ This is the single place that maps user-facing .cfg.toml names to internal
5
+ engine names. Keep it DB-neutral.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ import os
12
+ from typing import Any
13
+
14
+ try: # pragma: no cover - py311+ path is normal
15
+ import tomllib
16
+ except ModuleNotFoundError: # pragma: no cover
17
+ import tomli as tomllib # type: ignore[no-redef]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CollectionConfig:
22
+ name: str
23
+ id_field: str
24
+ live_when: dict[str, Any] = field(default_factory=dict)
25
+ ignore_fields: tuple[str, ...] = ()
26
+ ignore_patterns: tuple[str, ...] = ()
27
+ ignore_paths: tuple[str, ...] = ()
28
+ secret_fields: tuple[str, ...] = ()
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class HistoryConfig:
33
+ history_collection: str = "config_history"
34
+ heads_collection: str = "config_heads"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BranchesConfig:
39
+ enabled: bool = False
40
+ refs_collection: str = "cfgit_refs"
41
+ default_branch: str = "main"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class PermissionConfig:
46
+ mode: str = "open"
47
+ admins: tuple[str, ...] = ()
48
+ writers: tuple[str, ...] = ()
49
+ admin_actions: tuple[str, ...] = ()
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class IdentityTokenConfig:
54
+ author: str
55
+ token_hash: str
56
+ name: str | None = None
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class IdentityConfig:
61
+ mode: str = "open"
62
+ sources: tuple[str, ...] = ("db_principal", "token")
63
+ token_env: str = "CFGIT_IDENTITY_TOKEN"
64
+ tokens: tuple[IdentityTokenConfig, ...] = ()
65
+ principal_map: dict[str, str] = field(default_factory=dict)
66
+ fingerprint_chars: int = 5
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ConnectionsConfig:
71
+ enabled: bool = False
72
+ ai_provider: str = "openai"
73
+ share_with_ai: tuple[str, ...] = ()
74
+ warn_level: str = "none"
75
+ links: tuple[dict[str, Any], ...] = ()
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class SecretsConfig:
80
+ block_fields: tuple[str, ...] = ()
81
+ block_values: tuple[str, ...] = ()
82
+ on_match: str = "refuse"
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class EnvConfig:
87
+ name: str
88
+ database: str
89
+ uri: str
90
+ db: str
91
+ runtime_uri: str | None = None
92
+ runtime_db: str | None = None
93
+ history_uri: str | None = None
94
+ history_db: str | None = None
95
+ needs_approval: bool = False
96
+ identity: IdentityConfig = field(default_factory=IdentityConfig)
97
+ permissions: PermissionConfig = field(default_factory=PermissionConfig)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ProjectConfig:
102
+ name: str
103
+ path: Path
104
+ history: HistoryConfig
105
+ collections: tuple[CollectionConfig, ...]
106
+ envs: dict[str, EnvConfig]
107
+ branches: BranchesConfig = field(default_factory=BranchesConfig)
108
+ author_from: str = "git"
109
+ connections: ConnectionsConfig = field(default_factory=ConnectionsConfig)
110
+ secrets: SecretsConfig = field(default_factory=SecretsConfig)
111
+
112
+ def collection(self, name: str) -> CollectionConfig:
113
+ for coll in self.collections:
114
+ if coll.name == name:
115
+ return coll
116
+ raise KeyError(f"unknown collection: {name}")
117
+
118
+
119
+ def load_config(path: str | Path | None = None) -> ProjectConfig:
120
+ cfg_path = _resolve_config_path(path)
121
+ with cfg_path.open("rb") as f:
122
+ raw = tomllib.load(f)
123
+
124
+ collections = tuple(_load_collection(item) for item in raw.get("collection", []))
125
+ if not collections:
126
+ raise ValueError("config has no [[collection]] entries")
127
+
128
+ envs: dict[str, EnvConfig] = {}
129
+ for name, data in (raw.get("env") or {}).items():
130
+ permission_raw, identity_raw = _split_identity_permissions(data)
131
+ envs[name] = EnvConfig(
132
+ name=name,
133
+ database=str(data.get("database", "")),
134
+ uri=_resolve_uri(str(data.get("uri", "")), env_name=name),
135
+ db=str(data.get("db", "")),
136
+ runtime_uri=_resolve_optional_uri(data.get("runtime_uri"), env_name=name),
137
+ runtime_db=str(data["runtime_db"]) if data.get("runtime_db") is not None else None,
138
+ history_uri=_resolve_optional_uri(data.get("history_uri"), env_name=name),
139
+ history_db=str(data["history_db"]) if data.get("history_db") is not None else None,
140
+ needs_approval=bool(data.get("needs_approval", False)),
141
+ identity=_load_identity(identity_raw),
142
+ permissions=_load_permissions(permission_raw),
143
+ )
144
+
145
+ history_raw = raw.get("history") or {}
146
+ branches_raw = raw.get("branches") or {}
147
+ author_raw = raw.get("author") or {}
148
+ connections_raw = raw.get("connections") or {}
149
+ secrets_raw = raw.get("secrets") or {}
150
+ return ProjectConfig(
151
+ name=str((raw.get("project") or {}).get("name", cfg_path.parent.name)),
152
+ path=cfg_path,
153
+ history=HistoryConfig(
154
+ history_collection=str(history_raw.get("history_collection", "config_history")),
155
+ heads_collection=str(history_raw.get("heads_collection", "config_heads")),
156
+ ),
157
+ branches=_load_branches(branches_raw),
158
+ collections=collections,
159
+ envs=envs,
160
+ author_from=str(author_raw.get("from", "git")),
161
+ connections=_load_connections(connections_raw),
162
+ secrets=_load_secrets(secrets_raw),
163
+ )
164
+
165
+
166
+ def _resolve_config_path(path: str | Path | None) -> Path:
167
+ if path is not None:
168
+ return Path(path).expanduser().resolve()
169
+ local = Path(".cfg.toml")
170
+ if local.exists():
171
+ return local.resolve()
172
+ example = Path("examples/.cfg.toml")
173
+ if example.exists():
174
+ return example.resolve()
175
+ raise FileNotFoundError("no .cfg.toml found")
176
+
177
+
178
+ def _load_collection(data: dict[str, Any]) -> CollectionConfig:
179
+ ignore_patterns = (*data.get("ignore_patterns", ()), *data.get("ignore_globs", ()))
180
+ secret_fields = (*data.get("secret_fields", ()), *data.get("strip_on_store", ()))
181
+ return CollectionConfig(
182
+ name=str(data["name"]),
183
+ id_field=str(data["id_field"]),
184
+ live_when=dict(data.get("live_when") or {}),
185
+ ignore_fields=tuple(str(x) for x in data.get("ignore_fields", ())),
186
+ ignore_patterns=tuple(str(x) for x in ignore_patterns),
187
+ ignore_paths=tuple(str(x) for x in data.get("ignore_paths", ())),
188
+ secret_fields=tuple(str(x) for x in secret_fields),
189
+ )
190
+
191
+
192
+ def _load_branches(data: dict[str, Any]) -> BranchesConfig:
193
+ default_branch = str(data.get("default_branch", "main")).strip() or "main"
194
+ return BranchesConfig(
195
+ enabled=bool(data.get("enabled", False)),
196
+ refs_collection=str(data.get("refs_collection", "cfgit_refs")).strip() or "cfgit_refs",
197
+ default_branch=default_branch,
198
+ )
199
+
200
+
201
+ def _load_permissions(data: dict[str, Any]) -> PermissionConfig:
202
+ mode = str(data.get("mode", "open"))
203
+ if mode not in {"open", "restricted"}:
204
+ raise ValueError("permissions.mode must be open or restricted")
205
+ return PermissionConfig(
206
+ mode=mode,
207
+ admins=tuple(str(x) for x in data.get("admins", ())),
208
+ writers=tuple(str(x) for x in data.get("writers", ())),
209
+ admin_actions=tuple(str(x) for x in data.get("admin_actions", ())),
210
+ )
211
+
212
+
213
+ def _load_identity(data: dict[str, Any]) -> IdentityConfig:
214
+ mode = str(data.get("mode", "open")).lower()
215
+ if mode not in {"open", "authenticated", "enforced"}:
216
+ raise ValueError("identity.mode must be open, authenticated, or enforced")
217
+ raw_sources = data.get("sources", data.get("source"))
218
+ if raw_sources is None:
219
+ sources = ("db_principal", "token")
220
+ elif isinstance(raw_sources, str):
221
+ sources = (raw_sources,)
222
+ else:
223
+ sources = tuple(str(item) for item in raw_sources)
224
+ for source in sources:
225
+ if source not in {"db_principal", "token"}:
226
+ raise ValueError("identity.sources may contain only db_principal or token")
227
+ fingerprint_chars = int(data.get("fingerprint_chars", 5))
228
+ if not 4 <= fingerprint_chars <= 12:
229
+ raise ValueError("identity.fingerprint_chars must be between 4 and 12")
230
+ return IdentityConfig(
231
+ mode=mode,
232
+ sources=sources,
233
+ token_env=str(data.get("token_env", "CFGIT_IDENTITY_TOKEN")),
234
+ tokens=tuple(_load_identity_token(item) for item in _identity_token_items(data)),
235
+ principal_map={str(k): str(v) for k, v in dict(data.get("principal_map") or {}).items()},
236
+ fingerprint_chars=fingerprint_chars,
237
+ )
238
+
239
+
240
+ def _load_identity_token(data: dict[str, Any]) -> IdentityTokenConfig:
241
+ author = str(data.get("author") or "").strip()
242
+ token_hash = str(data.get("sha256") or data.get("hash") or data.get("token_hash") or "").strip()
243
+ if not author or not token_hash:
244
+ raise ValueError("identity token entries require author and sha256/hash")
245
+ return IdentityTokenConfig(
246
+ author=author,
247
+ token_hash=_normalize_token_hash(token_hash),
248
+ name=str(data["name"]) if data.get("name") is not None else None,
249
+ )
250
+
251
+
252
+ def _identity_token_items(data: dict[str, Any]) -> list[dict[str, Any]]:
253
+ raw = data.get("tokens", data.get("token", ()))
254
+ if isinstance(raw, dict):
255
+ return [raw]
256
+ return [dict(item) for item in raw]
257
+
258
+
259
+ def _normalize_token_hash(value: str) -> str:
260
+ raw = value.lower()
261
+ if raw.startswith("sha256:"):
262
+ hex_value = raw[7:]
263
+ else:
264
+ hex_value = raw
265
+ raw = f"sha256:{raw}"
266
+ if len(hex_value) != 64 or any(ch not in "0123456789abcdef" for ch in hex_value):
267
+ raise ValueError("identity token hash must be a sha256:<64 hex> value")
268
+ return raw
269
+
270
+
271
+ def _split_identity_permissions(data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
272
+ permissions = dict(data.get("permissions") or {})
273
+ identity = dict(data.get("identity") or {})
274
+ if data.get("identity_mode") is not None and identity.get("mode") is None:
275
+ identity["mode"] = data["identity_mode"]
276
+
277
+ permission_mode = str(permissions.get("mode", "open")).lower()
278
+ if permission_mode in {"authenticated", "enforced"}:
279
+ identity.setdefault("mode", permission_mode)
280
+ permissions["mode"] = "restricted"
281
+ return permissions, identity
282
+
283
+
284
+ def _load_connections(data: dict[str, Any]) -> ConnectionsConfig:
285
+ return ConnectionsConfig(
286
+ enabled=bool(data.get("enabled", False)),
287
+ ai_provider=str(data.get("ai_provider", "openai")),
288
+ share_with_ai=tuple(str(x) for x in data.get("share_with_ai", ())),
289
+ warn_level=str(data.get("warn_level", "none")),
290
+ links=tuple(dict(item) for item in data.get("links", ())),
291
+ )
292
+
293
+
294
+ def _load_secrets(data: dict[str, Any]) -> SecretsConfig:
295
+ on_match = str(data.get("on_match", "refuse"))
296
+ if on_match not in {"refuse", "warn"}:
297
+ raise ValueError("secrets.on_match must be refuse or warn")
298
+ block_fields = (*data.get("block_fields", ()), *data.get("deny_field_globs", ()))
299
+ block_values = (*data.get("block_values", ()), *data.get("deny_value_regex", ()))
300
+ return SecretsConfig(
301
+ block_fields=tuple(str(x) for x in block_fields),
302
+ block_values=tuple(str(x) for x in block_values),
303
+ on_match=on_match,
304
+ )
305
+
306
+
307
+ def _resolve_uri(raw: str, *, env_name: str) -> str:
308
+ if raw.startswith("env:"):
309
+ key = raw[4:]
310
+ value = os.environ.get(key)
311
+ if value:
312
+ return value
313
+ if env_name == "dev":
314
+ fallback = os.environ.get("MONGODB_URI")
315
+ if fallback:
316
+ return fallback
317
+ return ""
318
+ return raw
319
+
320
+
321
+ def _resolve_optional_uri(raw: Any, *, env_name: str) -> str | None:
322
+ if raw is None:
323
+ return None
324
+ return _resolve_uri(str(raw), env_name=env_name)
cfg/core/diff.py ADDED
@@ -0,0 +1,43 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Small structured diff helper."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def diff_values(before: Any, after: Any, path: str = "") -> list[dict[str, Any]]:
9
+ if before == after:
10
+ return []
11
+ if isinstance(before, dict) and isinstance(after, dict):
12
+ changes: list[dict[str, Any]] = []
13
+ keys = sorted(set(before) | set(after))
14
+ for key in keys:
15
+ child_path = f"{path}.{key}" if path else str(key)
16
+ if key not in before:
17
+ changes.append({"path": child_path, "op": "add", "before": None, "after": after[key]})
18
+ elif key not in after:
19
+ changes.append({"path": child_path, "op": "remove", "before": before[key], "after": None})
20
+ else:
21
+ changes.extend(diff_values(before[key], after[key], child_path))
22
+ return changes
23
+ return [{"path": path or "$", "op": "change", "before": before, "after": after}]
24
+
25
+
26
+ def format_diff(changes: list[dict[str, Any]]) -> str:
27
+ if not changes:
28
+ return "no changes"
29
+ lines: list[str] = []
30
+ for change in changes:
31
+ lines.append(f"{change['op']} {change['path']}")
32
+ if change["op"] != "add":
33
+ lines.append(f" before: {_short(change['before'])}")
34
+ if change["op"] != "remove":
35
+ lines.append(f" after: {_short(change['after'])}")
36
+ return "\n".join(lines)
37
+
38
+
39
+ def _short(value: Any, limit: int = 240) -> str:
40
+ text = repr(value)
41
+ if len(text) > limit:
42
+ return text[: limit - 3] + "..."
43
+ return text