superlocalmemory 3.7.8 → 3.8.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +129 -0
- package/README.md +205 -123
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +228 -17
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +187 -0
- package/src/superlocalmemory/cli/main.py +49 -8
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +21 -7
- package/src/superlocalmemory/core/embeddings.py +131 -46
- package/src/superlocalmemory/core/engine.py +41 -22
- package/src/superlocalmemory/core/engine_ingestion.py +359 -43
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -3
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +186 -29
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +148 -3
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +263 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +32 -79
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +12 -4
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +221 -47
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +317 -70
- package/src/superlocalmemory/server/routes/behavioral.py +349 -71
- package/src/superlocalmemory/server/routes/brain.py +69 -12
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +438 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +108 -26
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +189 -68
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +289 -118
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +200 -31
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +103 -158
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +247 -89
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +827 -72
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +132 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +136 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +303 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +100 -41
- package/src/superlocalmemory/ui/js/dashboard.js +403 -65
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +871 -0
- package/src/superlocalmemory/ui/js/od-brain.js +816 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +600 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +929 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
- package/src/superlocalmemory/ui/js/od-shell.js +809 -0
- package/src/superlocalmemory/ui/js/od-skills.js +600 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +17 -3
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-memory-advisor.md +0 -44
- package/plugin-src/agents/slm-optimize-advisor.md +0 -38
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -23
- package/plugin-src/manifest.json +0 -25
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -23
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -16
- 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)
|