superlocalmemory 3.7.7 → 3.8.0

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.
Files changed (262) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +85 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -2
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +96 -12
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/auth_middleware.py +28 -0
  116. package/src/superlocalmemory/infra/backup.py +12 -1
  117. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  118. package/src/superlocalmemory/infra/data_root.py +43 -4
  119. package/src/superlocalmemory/infra/event_bus.py +107 -24
  120. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  121. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  122. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  123. package/src/superlocalmemory/learning/cross_project.py +28 -19
  124. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  125. package/src/superlocalmemory/loops/__init__.py +56 -0
  126. package/src/superlocalmemory/loops/budget.py +58 -0
  127. package/src/superlocalmemory/loops/engine.py +164 -0
  128. package/src/superlocalmemory/loops/ledger.py +243 -0
  129. package/src/superlocalmemory/loops/models.py +152 -0
  130. package/src/superlocalmemory/loops/rules.py +52 -0
  131. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  132. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  133. package/src/superlocalmemory/mcp/profiles.py +103 -0
  134. package/src/superlocalmemory/mcp/server.py +21 -49
  135. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  136. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  137. package/src/superlocalmemory/mcp/tools_core.py +50 -5
  138. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  139. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  140. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  141. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  142. package/src/superlocalmemory/mesh/broker.py +237 -129
  143. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  144. package/src/superlocalmemory/optimize/NOTICE +1 -6
  145. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  147. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  148. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  149. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  150. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  151. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  152. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  153. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  154. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  155. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  156. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  157. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  158. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  159. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  160. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  161. package/src/superlocalmemory/retrieval/engine.py +168 -26
  162. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  163. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  164. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  165. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  166. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  167. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  168. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  169. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  170. package/src/superlocalmemory/server/api.py +21 -4
  171. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  172. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  173. package/src/superlocalmemory/server/recall_health.py +24 -3
  174. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  175. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  176. package/src/superlocalmemory/server/routes/agents.py +128 -38
  177. package/src/superlocalmemory/server/routes/backup.py +34 -10
  178. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  179. package/src/superlocalmemory/server/routes/brain.py +21 -5
  180. package/src/superlocalmemory/server/routes/chat.py +72 -16
  181. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  182. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  183. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  184. package/src/superlocalmemory/server/routes/entity.py +9 -4
  185. package/src/superlocalmemory/server/routes/events.py +24 -8
  186. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  187. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  188. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  189. package/src/superlocalmemory/server/routes/insights.py +3 -3
  190. package/src/superlocalmemory/server/routes/learning.py +14 -14
  191. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  192. package/src/superlocalmemory/server/routes/memories.py +221 -49
  193. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  194. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  195. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  196. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  197. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  198. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  199. package/src/superlocalmemory/server/routes/stats.py +13 -6
  200. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  201. package/src/superlocalmemory/server/routes/v3_api.py +194 -81
  202. package/src/superlocalmemory/server/routes/ws.py +5 -2
  203. package/src/superlocalmemory/server/security_middleware.py +12 -5
  204. package/src/superlocalmemory/server/ui.py +30 -5
  205. package/src/superlocalmemory/server/unified_daemon.py +431 -75
  206. package/src/superlocalmemory/server/write_identity.py +38 -8
  207. package/src/superlocalmemory/storage/database.py +265 -53
  208. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/models.py +4 -0
  217. package/src/superlocalmemory/storage/schema.py +87 -0
  218. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  219. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  220. package/src/superlocalmemory/trust/gate.py +49 -8
  221. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  222. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  223. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  224. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  225. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  226. package/src/superlocalmemory/ui/favicon.svg +35 -4
  227. package/src/superlocalmemory/ui/index.html +306 -173
  228. package/src/superlocalmemory/ui/js/brain.js +5 -20
  229. package/src/superlocalmemory/ui/js/core.js +47 -31
  230. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  231. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  232. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  233. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  234. package/src/superlocalmemory/ui/js/memories.js +15 -4
  235. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  236. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  237. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  238. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  239. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  240. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  241. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  242. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  243. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  244. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  245. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  246. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  247. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  248. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  249. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  250. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  251. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  252. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  253. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  254. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  255. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  256. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  257. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  258. package/src/superlocalmemory/ui/js/settings.js +2 -2
  259. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  260. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  261. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  262. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -0,0 +1,477 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — RBAC / teams (C3)
4
+
5
+ """Role-based access control for the memory tenancy model.
6
+
7
+ The tenancy boundary is the *profile* (individual / team / company). This
8
+ engine adds real, persisted user identity and role grants on top of that
9
+ boundary, following the Cognee reference model (Tenant -> User -> Role).
10
+
11
+ Design principles
12
+ -----------------
13
+ * **Additive / self-hosting-correct.** With zero users the daemon stays
14
+ single-operator: the machine operator (install token / daemon capability
15
+ over loopback) is the implicit *owner* with every permission, so individual
16
+ use has no login friction. Once an org creates users + memberships from the
17
+ dashboard, logged-in users are enforced against their role.
18
+ * **Enforced, not decorative.** ``has_permission`` is called from the write /
19
+ delete / share / manage routes. This module is the single source of truth
20
+ for the role -> permission matrix.
21
+ * **No plaintext secrets.** Passwords are hashed with ``hashlib.scrypt`` and a
22
+ per-user random salt; session tokens are stored only as SHA-256 hashes and
23
+ compared in constant time.
24
+
25
+ All state lives in memory.db (tables from migration M024). Connections are
26
+ short-lived (opened per call) with a bounded busy timeout, matching the mesh
27
+ broker's concurrency-safe pattern.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import hmac
34
+ import logging
35
+ import secrets
36
+ import sqlite3
37
+ import uuid
38
+ from datetime import datetime, timedelta, timezone
39
+ from enum import Enum
40
+ from pathlib import Path
41
+
42
+ logger = logging.getLogger("superlocalmemory.access.rbac")
43
+
44
+ _BUSY_TIMEOUT_MS = 4000
45
+ _SESSION_TTL_HOURS = 12
46
+
47
+ # scrypt cost parameters (OWASP-recommended interactive-login range).
48
+ _SCRYPT_N = 2 ** 14
49
+ _SCRYPT_R = 8
50
+ _SCRYPT_P = 1
51
+ _SCRYPT_DKLEN = 32
52
+
53
+
54
+ class Role(str, Enum):
55
+ ADMIN = "admin"
56
+ MEMBER = "member"
57
+ VIEWER = "viewer"
58
+
59
+
60
+ class Permission(str, Enum):
61
+ READ = "read"
62
+ WRITE = "write"
63
+ DELETE = "delete"
64
+ SHARE = "share"
65
+ MANAGE = "manage" # manage users, roles, profile settings
66
+
67
+
68
+ # Role -> permission set. This is the single source of truth for authorization.
69
+ _ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = {
70
+ Role.ADMIN: frozenset(Permission),
71
+ Role.MEMBER: frozenset({Permission.READ, Permission.WRITE, Permission.SHARE}),
72
+ Role.VIEWER: frozenset({Permission.READ}),
73
+ }
74
+
75
+
76
+ def permissions_for_role(role: Role) -> frozenset[Permission]:
77
+ return _ROLE_PERMISSIONS.get(role, frozenset())
78
+
79
+
80
+ class RbacError(ValueError):
81
+ """Raised for invalid RBAC operations (bad role, duplicate user, ...)."""
82
+
83
+
84
+ def _now() -> str:
85
+ return datetime.now(timezone.utc).isoformat()
86
+
87
+
88
+ def _uid() -> str:
89
+ return uuid.uuid4().hex[:16]
90
+
91
+
92
+ def _hash_password(password: str) -> str:
93
+ """scrypt$N$r$p$salt_hex$dk_hex — self-describing so params can evolve."""
94
+ if not password or len(password) < 8:
95
+ raise RbacError("Password must be at least 8 characters.")
96
+ salt = secrets.token_bytes(16)
97
+ dk = hashlib.scrypt(
98
+ password.encode("utf-8"), salt=salt,
99
+ n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN,
100
+ )
101
+ return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${dk.hex()}"
102
+
103
+
104
+ def _verify_password(password: str, stored: str) -> bool:
105
+ try:
106
+ scheme, n, r, p, salt_hex, dk_hex = stored.split("$")
107
+ if scheme != "scrypt":
108
+ return False
109
+ dk = hashlib.scrypt(
110
+ password.encode("utf-8"), salt=bytes.fromhex(salt_hex),
111
+ n=int(n), r=int(r), p=int(p), dklen=len(bytes.fromhex(dk_hex)),
112
+ )
113
+ return hmac.compare_digest(dk.hex(), dk_hex)
114
+ except (ValueError, TypeError):
115
+ return False
116
+
117
+
118
+ def _hash_token(raw_token: str) -> str:
119
+ return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
120
+
121
+
122
+ # Constant dummy hash so verify_credentials spends ~equal time on unknown and
123
+ # known usernames (defeats the username-enumeration timing oracle). Computed
124
+ # once at import.
125
+ _DUMMY_PASSWORD_HASH = _hash_password("__slm_dummy_sentinel__never_a_real_pw__")
126
+
127
+
128
+ class RbacEngine:
129
+ """Persisted user / membership / session store with role enforcement."""
130
+
131
+ def __init__(self, db_path: str | Path) -> None:
132
+ self._db_path = str(db_path)
133
+
134
+ # -- connection -------------------------------------------------------
135
+
136
+ def _conn(self) -> sqlite3.Connection:
137
+ conn = sqlite3.connect(self._db_path, timeout=_BUSY_TIMEOUT_MS / 1000)
138
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
139
+ conn.row_factory = sqlite3.Row
140
+ return conn
141
+
142
+ # -- users ------------------------------------------------------------
143
+
144
+ def user_count(self) -> int:
145
+ conn = self._conn()
146
+ try:
147
+ return int(conn.execute("SELECT COUNT(*) FROM rbac_users").fetchone()[0])
148
+ finally:
149
+ conn.close()
150
+
151
+ def create_user(self, username: str, password: str, display_name: str = "",
152
+ created_by: str = "owner") -> dict:
153
+ username = (username or "").strip()
154
+ if not username:
155
+ raise RbacError("Username is required.")
156
+ pw_hash = _hash_password(password)
157
+ user_id = _uid()
158
+ conn = self._conn()
159
+ try:
160
+ # The UNIQUE(username) constraint is the source of truth; the SELECT
161
+ # is a fast path. Two concurrent creates can both pass the SELECT, so
162
+ # the loser's INSERT hits the constraint — convert that to RbacError
163
+ # (409) instead of leaking a raw IntegrityError as a 500.
164
+ exists = conn.execute(
165
+ "SELECT 1 FROM rbac_users WHERE username=?", (username,)
166
+ ).fetchone()
167
+ if exists:
168
+ raise RbacError(f"User '{username}' already exists.")
169
+ try:
170
+ conn.execute(
171
+ "INSERT INTO rbac_users (user_id, username, display_name, "
172
+ "password_hash, status, created_at, created_by) "
173
+ "VALUES (?, ?, ?, ?, 'active', ?, ?)",
174
+ (user_id, username, display_name or username, pw_hash,
175
+ _now(), created_by),
176
+ )
177
+ conn.commit()
178
+ except sqlite3.IntegrityError:
179
+ raise RbacError(f"User '{username}' already exists.")
180
+ finally:
181
+ conn.close()
182
+ logger.info("RBAC: created user '%s' (%s)", username, user_id)
183
+ return {"user_id": user_id, "username": username,
184
+ "display_name": display_name or username, "status": "active"}
185
+
186
+ def set_password(self, user_id: str, password: str) -> None:
187
+ pw_hash = _hash_password(password)
188
+ conn = self._conn()
189
+ try:
190
+ cur = conn.execute(
191
+ "UPDATE rbac_users SET password_hash=? WHERE user_id=?",
192
+ (pw_hash, user_id),
193
+ )
194
+ if cur.rowcount == 0:
195
+ raise RbacError("User not found.")
196
+ # A password change invalidates every existing session.
197
+ conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
198
+ conn.commit()
199
+ finally:
200
+ conn.close()
201
+
202
+ def set_status(self, user_id: str, status: str) -> None:
203
+ if status not in ("active", "disabled"):
204
+ raise RbacError("status must be 'active' or 'disabled'.")
205
+ conn = self._conn()
206
+ try:
207
+ cur = conn.execute(
208
+ "UPDATE rbac_users SET status=? WHERE user_id=?", (status, user_id)
209
+ )
210
+ if cur.rowcount == 0:
211
+ raise RbacError("User not found.")
212
+ if status == "disabled":
213
+ conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
214
+ conn.commit()
215
+ finally:
216
+ conn.close()
217
+
218
+ def delete_user(self, user_id: str) -> None:
219
+ conn = self._conn()
220
+ try:
221
+ conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
222
+ conn.execute("DELETE FROM rbac_memberships WHERE user_id=?", (user_id,))
223
+ cur = conn.execute("DELETE FROM rbac_users WHERE user_id=?", (user_id,))
224
+ if cur.rowcount == 0:
225
+ raise RbacError("User not found.")
226
+ conn.commit()
227
+ finally:
228
+ conn.close()
229
+
230
+ def get_user(self, user_id: str) -> dict | None:
231
+ conn = self._conn()
232
+ try:
233
+ row = conn.execute(
234
+ "SELECT user_id, username, display_name, status, created_at, created_by "
235
+ "FROM rbac_users WHERE user_id=?", (user_id,)
236
+ ).fetchone()
237
+ return dict(row) if row else None
238
+ finally:
239
+ conn.close()
240
+
241
+ def list_users(self) -> list[dict]:
242
+ conn = self._conn()
243
+ try:
244
+ rows = conn.execute(
245
+ "SELECT user_id, username, display_name, status, created_at, created_by "
246
+ "FROM rbac_users ORDER BY username"
247
+ ).fetchall()
248
+ return [dict(r) for r in rows]
249
+ finally:
250
+ conn.close()
251
+
252
+ # -- credentials / sessions ------------------------------------------
253
+
254
+ def verify_credentials(self, username: str, password: str) -> dict | None:
255
+ conn = self._conn()
256
+ try:
257
+ row = conn.execute(
258
+ "SELECT user_id, username, display_name, password_hash, status "
259
+ "FROM rbac_users WHERE username=?", ((username or "").strip(),)
260
+ ).fetchone()
261
+ finally:
262
+ conn.close()
263
+ if not row or row["status"] != "active":
264
+ # Run a verify against a constant dummy hash so an unknown/disabled
265
+ # username costs the same wall-time as a wrong password — no timing
266
+ # oracle for username enumeration.
267
+ _verify_password(password, _DUMMY_PASSWORD_HASH)
268
+ return None
269
+ if not _verify_password(password, row["password_hash"]):
270
+ return None
271
+ return {"user_id": row["user_id"], "username": row["username"],
272
+ "display_name": row["display_name"]}
273
+
274
+ def create_session(self, user_id: str, ttl_hours: int = _SESSION_TTL_HOURS) -> str:
275
+ """Mint an opaque session token; only its hash is stored. Returns the
276
+ raw token (shown to the client once)."""
277
+ raw = secrets.token_urlsafe(32)
278
+ now = datetime.now(timezone.utc)
279
+ expires = (now + timedelta(hours=ttl_hours)).isoformat()
280
+ conn = self._conn()
281
+ try:
282
+ conn.execute(
283
+ "INSERT INTO rbac_sessions (token_hash, user_id, created_at, "
284
+ "expires_at, last_seen) VALUES (?, ?, ?, ?, ?)",
285
+ (_hash_token(raw), user_id, now.isoformat(), expires, now.isoformat()),
286
+ )
287
+ conn.commit()
288
+ finally:
289
+ conn.close()
290
+ return raw
291
+
292
+ def resolve_session(self, raw_token: str) -> dict | None:
293
+ """Return the active user for a session token, or None if invalid /
294
+ expired / disabled. Updates last_seen. Constant-time by hash lookup."""
295
+ if not raw_token:
296
+ return None
297
+ token_hash = _hash_token(raw_token)
298
+ now = datetime.now(timezone.utc)
299
+ conn = self._conn()
300
+ try:
301
+ row = conn.execute(
302
+ "SELECT s.user_id, s.expires_at, s.last_seen, "
303
+ "u.username, u.display_name, u.status "
304
+ "FROM rbac_sessions s JOIN rbac_users u ON u.user_id = s.user_id "
305
+ "WHERE s.token_hash=?", (token_hash,)
306
+ ).fetchone()
307
+ if not row:
308
+ return None
309
+ try:
310
+ expired = datetime.fromisoformat(row["expires_at"]) <= now
311
+ except ValueError:
312
+ expired = True
313
+ if expired or row["status"] != "active":
314
+ conn.execute("DELETE FROM rbac_sessions WHERE token_hash=?", (token_hash,))
315
+ conn.commit()
316
+ return None
317
+ # Debounce last_seen: only write when it is stale (>60s), so a burst
318
+ # of authenticated requests does not serialize through the single
319
+ # SQLite writer on every call.
320
+ stale = True
321
+ try:
322
+ stale = (now - datetime.fromisoformat(row["last_seen"])).total_seconds() > 60
323
+ except (ValueError, TypeError, KeyError):
324
+ stale = True
325
+ if stale:
326
+ conn.execute(
327
+ "UPDATE rbac_sessions SET last_seen=? WHERE token_hash=?",
328
+ (now.isoformat(), token_hash),
329
+ )
330
+ conn.commit()
331
+ return {"user_id": row["user_id"], "username": row["username"],
332
+ "display_name": row["display_name"]}
333
+ finally:
334
+ conn.close()
335
+
336
+ def revoke_session(self, raw_token: str) -> None:
337
+ conn = self._conn()
338
+ try:
339
+ conn.execute("DELETE FROM rbac_sessions WHERE token_hash=?",
340
+ (_hash_token(raw_token),))
341
+ conn.commit()
342
+ finally:
343
+ conn.close()
344
+
345
+ def purge_expired_sessions(self) -> int:
346
+ conn = self._conn()
347
+ try:
348
+ cur = conn.execute(
349
+ "DELETE FROM rbac_sessions WHERE expires_at <= ?", (_now(),)
350
+ )
351
+ conn.commit()
352
+ return cur.rowcount or 0
353
+ finally:
354
+ conn.close()
355
+
356
+ # -- memberships ------------------------------------------------------
357
+
358
+ def set_membership(self, profile_id: str, user_id: str, role: str,
359
+ added_by: str = "owner") -> dict:
360
+ try:
361
+ role_enum = Role(role)
362
+ except ValueError:
363
+ raise RbacError(f"Invalid role '{role}'. Use admin/member/viewer.")
364
+ conn = self._conn()
365
+ try:
366
+ if not conn.execute(
367
+ "SELECT 1 FROM rbac_users WHERE user_id=?", (user_id,)
368
+ ).fetchone():
369
+ raise RbacError("User not found.")
370
+ conn.execute(
371
+ "INSERT INTO rbac_memberships (profile_id, user_id, role, added_at, added_by) "
372
+ "VALUES (?, ?, ?, ?, ?) "
373
+ "ON CONFLICT(profile_id, user_id) DO UPDATE SET role=excluded.role, "
374
+ "added_at=excluded.added_at, added_by=excluded.added_by",
375
+ (profile_id, user_id, role_enum.value, _now(), added_by),
376
+ )
377
+ conn.commit()
378
+ finally:
379
+ conn.close()
380
+ return {"profile_id": profile_id, "user_id": user_id, "role": role_enum.value}
381
+
382
+ def remove_membership(self, profile_id: str, user_id: str) -> None:
383
+ conn = self._conn()
384
+ try:
385
+ conn.execute(
386
+ "DELETE FROM rbac_memberships WHERE profile_id=? AND user_id=?",
387
+ (profile_id, user_id),
388
+ )
389
+ conn.commit()
390
+ finally:
391
+ conn.close()
392
+
393
+ def get_role(self, user_id: str, profile_id: str) -> Role | None:
394
+ conn = self._conn()
395
+ try:
396
+ row = conn.execute(
397
+ "SELECT role FROM rbac_memberships WHERE user_id=? AND profile_id=?",
398
+ (user_id, profile_id),
399
+ ).fetchone()
400
+ finally:
401
+ conn.close()
402
+ if not row:
403
+ return None
404
+ try:
405
+ return Role(row["role"])
406
+ except ValueError:
407
+ return None
408
+
409
+ def list_members(self, profile_id: str) -> list[dict]:
410
+ conn = self._conn()
411
+ try:
412
+ rows = conn.execute(
413
+ "SELECT m.user_id, u.username, u.display_name, m.role, m.added_at "
414
+ "FROM rbac_memberships m JOIN rbac_users u ON u.user_id = m.user_id "
415
+ "WHERE m.profile_id=? ORDER BY u.username", (profile_id,)
416
+ ).fetchall()
417
+ return [dict(r) for r in rows]
418
+ finally:
419
+ conn.close()
420
+
421
+ def list_user_profiles(self, user_id: str) -> list[dict]:
422
+ conn = self._conn()
423
+ try:
424
+ rows = conn.execute(
425
+ "SELECT profile_id, role FROM rbac_memberships WHERE user_id=?",
426
+ (user_id,)
427
+ ).fetchall()
428
+ return [dict(r) for r in rows]
429
+ finally:
430
+ conn.close()
431
+
432
+ # -- policy settings --------------------------------------------------
433
+
434
+ def get_policy(self, key: str, default: str = "") -> str:
435
+ conn = self._conn()
436
+ try:
437
+ row = conn.execute(
438
+ "SELECT value FROM rbac_settings WHERE key=?", (key,)
439
+ ).fetchone()
440
+ return row["value"] if row else default
441
+ finally:
442
+ conn.close()
443
+
444
+ def set_policy(self, key: str, value: str) -> None:
445
+ conn = self._conn()
446
+ try:
447
+ conn.execute(
448
+ "INSERT INTO rbac_settings (key, value) VALUES (?, ?) "
449
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
450
+ (key, value),
451
+ )
452
+ conn.commit()
453
+ finally:
454
+ conn.close()
455
+
456
+ def require_login(self) -> bool:
457
+ """Company mode: mutations require a valid user session (no owner
458
+ bypass). Default off (personal / single-operator use)."""
459
+ return self.get_policy("require_login", "0") == "1"
460
+
461
+ def set_require_login(self, enabled: bool) -> None:
462
+ self.set_policy("require_login", "1" if enabled else "0")
463
+
464
+ # -- authorization ----------------------------------------------------
465
+
466
+ def has_permission(self, user_id: str, profile_id: str,
467
+ permission: Permission | str) -> bool:
468
+ """True iff the user's role on the profile grants ``permission``.
469
+
470
+ A user with no membership on the profile has NO access (deny-by-default).
471
+ The implicit machine owner is handled by the caller, not here.
472
+ """
473
+ perm = permission if isinstance(permission, Permission) else Permission(permission)
474
+ role = self.get_role(user_id, profile_id)
475
+ if role is None:
476
+ return False
477
+ return perm in permissions_for_role(role)