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/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
cfg/core/hashing.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Canonical record hashing for cfgit."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
import fnmatch
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import unicodedata
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cfg.core.config import CollectionConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def stored_doc(doc: dict[str, Any], coll: CollectionConfig) -> dict[str, Any]:
|
|
19
|
+
"""Return the doc shape stored in history.
|
|
20
|
+
|
|
21
|
+
Secret fields are removed. Ignored fields stay stored but do not hash.
|
|
22
|
+
"""
|
|
23
|
+
out = deepcopy(doc)
|
|
24
|
+
for path in coll.secret_fields:
|
|
25
|
+
_drop_path(out, path)
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hash_doc(doc: dict[str, Any], coll: CollectionConfig) -> str:
|
|
30
|
+
stripped = strip_for_hash(doc, coll)
|
|
31
|
+
payload = json.dumps(_normalize(stripped), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
32
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def strip_for_hash(doc: dict[str, Any], coll: CollectionConfig) -> dict[str, Any]:
|
|
36
|
+
out = deepcopy(doc)
|
|
37
|
+
for key in list(out.keys()):
|
|
38
|
+
if key in coll.ignore_fields:
|
|
39
|
+
out.pop(key, None)
|
|
40
|
+
continue
|
|
41
|
+
if any(fnmatch.fnmatchcase(key, pat) for pat in coll.ignore_patterns):
|
|
42
|
+
out.pop(key, None)
|
|
43
|
+
for path in coll.ignore_paths:
|
|
44
|
+
_drop_path(out, path)
|
|
45
|
+
for path in coll.secret_fields:
|
|
46
|
+
_drop_path(out, path)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _drop_path(doc: dict[str, Any], dotted: str) -> None:
|
|
51
|
+
parts = [p for p in dotted.split(".") if p]
|
|
52
|
+
if not parts:
|
|
53
|
+
return
|
|
54
|
+
cur: Any = doc
|
|
55
|
+
for part in parts[:-1]:
|
|
56
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
57
|
+
return
|
|
58
|
+
cur = cur[part]
|
|
59
|
+
if isinstance(cur, dict):
|
|
60
|
+
cur.pop(parts[-1], None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalize(value: Any) -> Any:
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
return {
|
|
66
|
+
str(k): _normalize(v)
|
|
67
|
+
for k, v in value.items()
|
|
68
|
+
if v is not None
|
|
69
|
+
}
|
|
70
|
+
if isinstance(value, (list, tuple)):
|
|
71
|
+
return [_normalize(v) for v in value]
|
|
72
|
+
if _is_bson_decimal128(value):
|
|
73
|
+
return _normalize(value.to_decimal())
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
return unicodedata.normalize("NFC", value)
|
|
76
|
+
if isinstance(value, bool) or value is None:
|
|
77
|
+
return value
|
|
78
|
+
if isinstance(value, int):
|
|
79
|
+
return value
|
|
80
|
+
if isinstance(value, float):
|
|
81
|
+
if math.isnan(value) or math.isinf(value):
|
|
82
|
+
raise ValueError("cannot hash NaN or Infinity")
|
|
83
|
+
if value.is_integer():
|
|
84
|
+
return int(value)
|
|
85
|
+
return Decimal(str(value)).normalize().to_eng_string()
|
|
86
|
+
if isinstance(value, Decimal):
|
|
87
|
+
if value == value.to_integral_value():
|
|
88
|
+
return int(value)
|
|
89
|
+
return value.normalize().to_eng_string()
|
|
90
|
+
if isinstance(value, datetime):
|
|
91
|
+
dt = value
|
|
92
|
+
if dt.tzinfo is None:
|
|
93
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
94
|
+
dt = dt.astimezone(timezone.utc)
|
|
95
|
+
dt = dt.replace(microsecond=(dt.microsecond // 1000) * 1000)
|
|
96
|
+
return dt.isoformat().replace("+00:00", "Z")
|
|
97
|
+
return str(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_bson_decimal128(value: Any) -> bool:
|
|
101
|
+
cls = value.__class__
|
|
102
|
+
return cls.__name__ == "Decimal128" and cls.__module__.startswith("bson.")
|
cfg/core/identity.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Identity resolution for cfgit.
|
|
3
|
+
|
|
4
|
+
The short fingerprint is for display only. Authentication always uses either a
|
|
5
|
+
database-authenticated principal or a full SHA-256 token hash comparison.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import getpass
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from cfg.core.config import EnvConfig, IdentityConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
MIN_TOKEN_LENGTH = 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IdentityError(ValueError):
|
|
24
|
+
"""The environment requires verified identity and cfgit could not prove it."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Identity:
|
|
29
|
+
author: str
|
|
30
|
+
mode: str
|
|
31
|
+
source: str
|
|
32
|
+
authenticated: bool
|
|
33
|
+
fingerprint: str
|
|
34
|
+
principal: str | None = None
|
|
35
|
+
credential: str | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def display(self) -> str:
|
|
39
|
+
return f"{self.author}#{self.fingerprint}"
|
|
40
|
+
|
|
41
|
+
def history_meta(self) -> dict[str, Any]:
|
|
42
|
+
meta: dict[str, Any] = {
|
|
43
|
+
"mode": self.mode,
|
|
44
|
+
"author": self.author,
|
|
45
|
+
"source": self.source,
|
|
46
|
+
"authenticated": self.authenticated,
|
|
47
|
+
"fingerprint": self.fingerprint,
|
|
48
|
+
}
|
|
49
|
+
if self.principal:
|
|
50
|
+
meta["principal"] = self.principal
|
|
51
|
+
if self.credential:
|
|
52
|
+
meta["credential"] = self.credential
|
|
53
|
+
return meta
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_identity(
|
|
57
|
+
env: EnvConfig,
|
|
58
|
+
adapter: Any,
|
|
59
|
+
*,
|
|
60
|
+
explicit_author: str | None = None,
|
|
61
|
+
) -> Identity:
|
|
62
|
+
cfg = env.identity
|
|
63
|
+
if cfg.mode == "open":
|
|
64
|
+
return self_asserted_identity(resolve_self_asserted_author(explicit_author), cfg=cfg)
|
|
65
|
+
|
|
66
|
+
errors: list[str] = []
|
|
67
|
+
for source in cfg.sources:
|
|
68
|
+
if source == "token":
|
|
69
|
+
identity, error = _identity_from_token(cfg, explicit_author=explicit_author)
|
|
70
|
+
elif source == "db_principal":
|
|
71
|
+
identity, error = _identity_from_db_principal(cfg, adapter, explicit_author=explicit_author)
|
|
72
|
+
else: # config validation should prevent this
|
|
73
|
+
identity, error = None, f"unsupported identity source {source}"
|
|
74
|
+
if identity is not None:
|
|
75
|
+
return identity
|
|
76
|
+
if error:
|
|
77
|
+
errors.append(error)
|
|
78
|
+
|
|
79
|
+
detail = "; ".join(errors) if errors else "no configured identity source produced a verified identity"
|
|
80
|
+
raise IdentityError(
|
|
81
|
+
f"{env.name} requires {cfg.mode} identity, but cfgit could not verify the caller: {detail}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_self_asserted_author(explicit: str | None = None) -> str:
|
|
86
|
+
if explicit:
|
|
87
|
+
return explicit
|
|
88
|
+
if os.environ.get("CFG_AUTHOR"):
|
|
89
|
+
return os.environ["CFG_AUTHOR"]
|
|
90
|
+
try:
|
|
91
|
+
out = subprocess.check_output(["git", "config", "user.email"], text=True).strip()
|
|
92
|
+
if out:
|
|
93
|
+
return out
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return getpass.getuser()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def self_asserted_identity(author: str, *, cfg: IdentityConfig) -> Identity:
|
|
100
|
+
return _identity(
|
|
101
|
+
author=author,
|
|
102
|
+
cfg=cfg,
|
|
103
|
+
mode=cfg.mode,
|
|
104
|
+
source="self_asserted",
|
|
105
|
+
authenticated=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def hash_token(token: str) -> str:
|
|
110
|
+
return "sha256:" + hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def token_fingerprint(token_hash: str, *, chars: int = 5) -> str:
|
|
114
|
+
return _hash_hex(token_hash)[:chars]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _identity_from_token(
|
|
118
|
+
cfg: IdentityConfig,
|
|
119
|
+
*,
|
|
120
|
+
explicit_author: str | None,
|
|
121
|
+
) -> tuple[Identity | None, str | None]:
|
|
122
|
+
raw = os.environ.get(cfg.token_env)
|
|
123
|
+
if not raw:
|
|
124
|
+
return None, f"{cfg.token_env} is not set"
|
|
125
|
+
token = raw.strip()
|
|
126
|
+
if len(token) < MIN_TOKEN_LENGTH:
|
|
127
|
+
return None, (
|
|
128
|
+
f"{cfg.token_env} is too short; identity tokens need at least {MIN_TOKEN_LENGTH} "
|
|
129
|
+
"characters, and short fingerprints are display-only"
|
|
130
|
+
)
|
|
131
|
+
hashed = hash_token(token)
|
|
132
|
+
for item in cfg.tokens:
|
|
133
|
+
if hmac.compare_digest(hashed, item.token_hash):
|
|
134
|
+
_reject_author_mismatch(explicit_author, item.author)
|
|
135
|
+
credential = item.name or f"token:{token_fingerprint(item.token_hash, chars=cfg.fingerprint_chars)}"
|
|
136
|
+
return (
|
|
137
|
+
Identity(
|
|
138
|
+
author=item.author,
|
|
139
|
+
mode=cfg.mode,
|
|
140
|
+
source="token",
|
|
141
|
+
authenticated=True,
|
|
142
|
+
fingerprint=token_fingerprint(item.token_hash, chars=cfg.fingerprint_chars),
|
|
143
|
+
principal=credential,
|
|
144
|
+
credential=credential,
|
|
145
|
+
),
|
|
146
|
+
None,
|
|
147
|
+
)
|
|
148
|
+
return None, f"{cfg.token_env} did not match any configured identity token hash"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _identity_from_db_principal(
|
|
152
|
+
cfg: IdentityConfig,
|
|
153
|
+
adapter: Any,
|
|
154
|
+
*,
|
|
155
|
+
explicit_author: str | None,
|
|
156
|
+
) -> tuple[Identity | None, str | None]:
|
|
157
|
+
getter = getattr(adapter, "authenticated_principal", None)
|
|
158
|
+
if not callable(getter):
|
|
159
|
+
return None, f"{adapter.backend_name()} adapter does not expose an authenticated DB principal"
|
|
160
|
+
principal = getter()
|
|
161
|
+
if not principal:
|
|
162
|
+
return None, f"{adapter.backend_name()} connection has no authenticated DB principal"
|
|
163
|
+
author = cfg.principal_map.get(principal) or cfg.principal_map.get(principal.lower()) or principal
|
|
164
|
+
_reject_author_mismatch(explicit_author, author)
|
|
165
|
+
return (
|
|
166
|
+
_identity(
|
|
167
|
+
author=author,
|
|
168
|
+
cfg=cfg,
|
|
169
|
+
mode=cfg.mode,
|
|
170
|
+
source="db_principal",
|
|
171
|
+
authenticated=True,
|
|
172
|
+
principal=principal,
|
|
173
|
+
),
|
|
174
|
+
None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _identity(
|
|
179
|
+
*,
|
|
180
|
+
author: str,
|
|
181
|
+
cfg: IdentityConfig,
|
|
182
|
+
mode: str,
|
|
183
|
+
source: str,
|
|
184
|
+
authenticated: bool,
|
|
185
|
+
principal: str | None = None,
|
|
186
|
+
credential: str | None = None,
|
|
187
|
+
) -> Identity:
|
|
188
|
+
return Identity(
|
|
189
|
+
author=author,
|
|
190
|
+
mode=mode,
|
|
191
|
+
source=source,
|
|
192
|
+
authenticated=authenticated,
|
|
193
|
+
principal=principal,
|
|
194
|
+
credential=credential,
|
|
195
|
+
fingerprint=_fingerprint(author=author, source=source, principal=principal, chars=cfg.fingerprint_chars),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fingerprint(*, author: str, source: str, principal: str | None, chars: int) -> str:
|
|
200
|
+
value = "\0".join([source, author, principal or ""])
|
|
201
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:chars]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _reject_author_mismatch(explicit_author: str | None, verified_author: str) -> None:
|
|
205
|
+
if explicit_author and explicit_author.strip().lower() != verified_author.strip().lower():
|
|
206
|
+
raise IdentityError(
|
|
207
|
+
f"--author {explicit_author} does not match verified identity {verified_author}; "
|
|
208
|
+
"verified modes do not accept self-asserted author strings"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _hash_hex(value: str) -> str:
|
|
213
|
+
return value.lower().removeprefix("sha256:")
|