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/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:")
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Shared interfaces for CLI, MCP, and UI."""