multi-user-rbac 0.1.1__tar.gz

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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi-user-rbac
3
+ Version: 0.1.1
4
+ Summary: Hermes plugin implementing multi-user RBAC, preferences, and owner controls.
5
+ Author: Ahsan Athallah
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/
8
+ Project-URL: Repository, https://github.com/
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: pyyaml>=6.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: build; extra == "dev"
15
+
16
+ # Multi-user RBAC for Hermes
17
+
18
+ This plugin keeps per-platform, per-channel roles and preferences so Hermes can enforce owner/user/guest permissions and personalize responses. It stores all state in `~/.hermes/plugins/multi-user-rbac/data/users.db` and wires hooks/tools/commands following the Hermes plugin guide.
19
+
20
+ ## Installation
21
+ 1. Copy this directory to `~/.hermes/plugins/multi-user-rbac/`.
22
+ 2. Optional: install the Python dependencies (Hermes already bundles `sqlite3`, but you need PyYAML for owner configuration):
23
+ ```bash
24
+ pip install pyyaml
25
+ ```
26
+ 3. Restart Hermes. You should see `multi-user-rbac` in `/plugins` and a new set of tools/commands.
27
+
28
+ ## Configuration
29
+ Create `~/.hermes/plugins/multi-user-rbac/config.yaml` to tell the plugin who the owners are:
30
+
31
+ ```yaml
32
+ owner:
33
+ telegram:
34
+ - 123456789 # replace with your Telegram ID
35
+ discord:
36
+ - 987654321 # replace with your Discord ID
37
+ ```
38
+
39
+ Owner IDs are promoted automatically on first interaction. No config file means everyone starts as `guest`.
40
+
41
+ ## Tools
42
+ | Tool | Description | Role access |
43
+ | --- | --- | --- |
44
+ | `identify_user` | Returns the current session user identity (platform, display name, role). | Everyone |
45
+ | `get_user_preference` | Reads a preference (global or channel-scoped) for the caller. | Owner/User |
46
+ | `set_user_preference` | Stores a preference for the caller (optional `channel_id`). | Owner/User |
47
+ | `set_channel_preference` | Owners set channel-wide defaults. | Owner only |
48
+ | `list_channel_users` | Lists channel members and their roles; auto-refreshes membership when users join. | Owner/User |
49
+ | `promote_user` / `demote_user` | Owner-only tools to change another member’s role. | Owner only |
50
+
51
+ ## Hooks
52
+ - `on_session_start`: identifies who is speaking, auto-promotes owners, records channel membership.
53
+ - `pre_llm_call`: injects a short summary of the current user + recent preferences into every turn.
54
+ - `post_tool_call`: logs all tool usage, records denied access attempts for auditing.
55
+ - `on_session_finalize`: clears per-session context so new sessions start clean.
56
+
57
+ ## Commands
58
+ - `/users`: owner-only list of channel members (mirrors `list_channel_users`).
59
+ - `/promote <platform_user_id>`: owner-only; promotes the chosen member to `user`.
60
+ - `/demote <platform_user_id>`: owner-only; demotes the chosen member to `guest`.
61
+ - `/set-for <platform_user_id> <key> <value>`: owner-only; set preferences on behalf of someone else.
62
+
63
+ ## Testing
64
+ Run the bundled tests with `pytest tests`. The suite covers storage, permission checks, and the tool handlers so you can safely refactor the RBAC rules.
65
+
66
+ ## Packaging & release
67
+ 1. Run `./build_release.sh` (or `python3 -m build --outdir dist`) from the plugin root; a wheel and sdist land in `dist/`.
68
+ 2. Install the release locally with `pip install dist/multi_user_rbac-0.1.0-py3-none-any.whl` or publish `dist/` to PyPI/Nexus.
69
+ 3. Hermes auto-discovers the plugin when `multi-user-rbac` is installed via an entry point (`[project.entry-points."hermes_agent.plugins"]` in `pyproject.toml`). Restart Hermes after installation.
70
+
71
+ ## Next steps
72
+ 1. Wire the plugin into your agent by placing it under `~/.hermes/plugins/` and restarting Hermes.
73
+ 2. Keep `config.yaml` in sync with the owners you trust; the plugin auto-promotes them on first use.
74
+ 3. Extend tools/commands if you need more automation (e.g., CLI commands or skills).
@@ -0,0 +1,59 @@
1
+ # Multi-user RBAC for Hermes
2
+
3
+ This plugin keeps per-platform, per-channel roles and preferences so Hermes can enforce owner/user/guest permissions and personalize responses. It stores all state in `~/.hermes/plugins/multi-user-rbac/data/users.db` and wires hooks/tools/commands following the Hermes plugin guide.
4
+
5
+ ## Installation
6
+ 1. Copy this directory to `~/.hermes/plugins/multi-user-rbac/`.
7
+ 2. Optional: install the Python dependencies (Hermes already bundles `sqlite3`, but you need PyYAML for owner configuration):
8
+ ```bash
9
+ pip install pyyaml
10
+ ```
11
+ 3. Restart Hermes. You should see `multi-user-rbac` in `/plugins` and a new set of tools/commands.
12
+
13
+ ## Configuration
14
+ Create `~/.hermes/plugins/multi-user-rbac/config.yaml` to tell the plugin who the owners are:
15
+
16
+ ```yaml
17
+ owner:
18
+ telegram:
19
+ - 123456789 # replace with your Telegram ID
20
+ discord:
21
+ - 987654321 # replace with your Discord ID
22
+ ```
23
+
24
+ Owner IDs are promoted automatically on first interaction. No config file means everyone starts as `guest`.
25
+
26
+ ## Tools
27
+ | Tool | Description | Role access |
28
+ | --- | --- | --- |
29
+ | `identify_user` | Returns the current session user identity (platform, display name, role). | Everyone |
30
+ | `get_user_preference` | Reads a preference (global or channel-scoped) for the caller. | Owner/User |
31
+ | `set_user_preference` | Stores a preference for the caller (optional `channel_id`). | Owner/User |
32
+ | `set_channel_preference` | Owners set channel-wide defaults. | Owner only |
33
+ | `list_channel_users` | Lists channel members and their roles; auto-refreshes membership when users join. | Owner/User |
34
+ | `promote_user` / `demote_user` | Owner-only tools to change another member’s role. | Owner only |
35
+
36
+ ## Hooks
37
+ - `on_session_start`: identifies who is speaking, auto-promotes owners, records channel membership.
38
+ - `pre_llm_call`: injects a short summary of the current user + recent preferences into every turn.
39
+ - `post_tool_call`: logs all tool usage, records denied access attempts for auditing.
40
+ - `on_session_finalize`: clears per-session context so new sessions start clean.
41
+
42
+ ## Commands
43
+ - `/users`: owner-only list of channel members (mirrors `list_channel_users`).
44
+ - `/promote <platform_user_id>`: owner-only; promotes the chosen member to `user`.
45
+ - `/demote <platform_user_id>`: owner-only; demotes the chosen member to `guest`.
46
+ - `/set-for <platform_user_id> <key> <value>`: owner-only; set preferences on behalf of someone else.
47
+
48
+ ## Testing
49
+ Run the bundled tests with `pytest tests`. The suite covers storage, permission checks, and the tool handlers so you can safely refactor the RBAC rules.
50
+
51
+ ## Packaging & release
52
+ 1. Run `./build_release.sh` (or `python3 -m build --outdir dist`) from the plugin root; a wheel and sdist land in `dist/`.
53
+ 2. Install the release locally with `pip install dist/multi_user_rbac-0.1.0-py3-none-any.whl` or publish `dist/` to PyPI/Nexus.
54
+ 3. Hermes auto-discovers the plugin when `multi-user-rbac` is installed via an entry point (`[project.entry-points."hermes_agent.plugins"]` in `pyproject.toml`). Restart Hermes after installation.
55
+
56
+ ## Next steps
57
+ 1. Wire the plugin into your agent by placing it under `~/.hermes/plugins/` and restarting Hermes.
58
+ 2. Keep `config.yaml` in sync with the owners you trust; the plugin auto-promotes them on first use.
59
+ 3. Extend tools/commands if you need more automation (e.g., CLI commands or skills).
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from . import schemas, tools
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def register(ctx) -> None:
11
+ logger.info("Registering multi-user RBAC plugin with Hermes")
12
+ toolset = "multi-user-rbac"
13
+ ctx.register_tool(
14
+ name=schemas.IDENTIFY_USER["name"],
15
+ toolset=toolset,
16
+ schema=schemas.IDENTIFY_USER,
17
+ handler=tools.identify_user,
18
+ )
19
+ ctx.register_tool(
20
+ name=schemas.GET_USER_PREFERENCE["name"],
21
+ toolset=toolset,
22
+ schema=schemas.GET_USER_PREFERENCE,
23
+ handler=tools.get_user_preference,
24
+ )
25
+ ctx.register_tool(
26
+ name=schemas.SET_USER_PREFERENCE["name"],
27
+ toolset=toolset,
28
+ schema=schemas.SET_USER_PREFERENCE,
29
+ handler=tools.set_user_preference,
30
+ )
31
+ ctx.register_tool(
32
+ name=schemas.SET_CHANNEL_PREFERENCE["name"],
33
+ toolset=toolset,
34
+ schema=schemas.SET_CHANNEL_PREFERENCE,
35
+ handler=tools.set_channel_preference,
36
+ )
37
+ ctx.register_tool(
38
+ name=schemas.LIST_CHANNEL_USERS["name"],
39
+ toolset=toolset,
40
+ schema=schemas.LIST_CHANNEL_USERS,
41
+ handler=tools.list_channel_users,
42
+ )
43
+ ctx.register_tool(
44
+ name=schemas.PROMOTE_USER["name"],
45
+ toolset=toolset,
46
+ schema=schemas.PROMOTE_USER,
47
+ handler=tools.promote_user,
48
+ )
49
+ ctx.register_tool(
50
+ name=schemas.DEMOTE_USER["name"],
51
+ toolset=toolset,
52
+ schema=schemas.DEMOTE_USER,
53
+ handler=tools.demote_user,
54
+ )
55
+ ctx.register_hook("on_session_start", tools.on_session_start)
56
+ ctx.register_hook("pre_llm_call", tools.on_pre_llm_call)
57
+ ctx.register_hook("post_tool_call", tools.on_post_tool_call)
58
+ ctx.register_hook("on_session_finalize", tools.on_session_finalize)
59
+ ctx.register_command(
60
+ "users", handler=tools.cmd_users, description="List channel members and roles"
61
+ )
62
+ ctx.register_command("promote", handler=tools.cmd_promote, description="Promote a user")
63
+ ctx.register_command("demote", handler=tools.cmd_demote, description="Demote a user")
64
+ ctx.register_command(
65
+ "set-for", handler=tools.cmd_set_preference_for, description="Owner: set preference for another"
66
+ )
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ try:
7
+ import yaml
8
+ except ImportError: # pragma: no cover - optional dependency
9
+ yaml = None
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ CONFIG_DIR = Path.home() / ".hermes" / "plugins" / "multi-user-rbac"
14
+ CONFIG_PATH = CONFIG_DIR / "config.yaml"
15
+
16
+
17
+ def load_owner_config() -> dict[str, set[str]]:
18
+ if not CONFIG_PATH.exists():
19
+ return {}
20
+ if yaml is None:
21
+ logger.warning("PyYAML not installed; owner config disabled")
22
+ return {}
23
+ try:
24
+ data = yaml.safe_load(CONFIG_PATH.read_text()) or {}
25
+ except Exception as exc:
26
+ logger.warning("Unable to parse %s: %s", CONFIG_PATH, exc)
27
+ return {}
28
+
29
+ raw_owners = data.get("owner") or {}
30
+ normalized: dict[str, set[str]] = {}
31
+ for platform, ids in raw_owners.items():
32
+ if isinstance(ids, (list, tuple)):
33
+ normalized[platform.lower()] = {str(i) for i in ids if i is not None}
34
+ elif isinstance(ids, str):
35
+ normalized.setdefault(platform.lower(), set()).add(ids)
36
+ return normalized
37
+
38
+
39
+ def is_owner(platform: str | None, platform_user_id: str | int, owners: dict[str, set[str]] | None = None) -> bool:
40
+ if not platform or not platform_user_id:
41
+ return False
42
+ owners = owners or load_owner_config()
43
+ bucket = owners.get(platform.lower(), set())
44
+ return str(platform_user_id) in bucket
45
+
46
+
47
+ def ensure_config_dir() -> None:
48
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ ROLE_PERMISSIONS: dict[str, frozenset[str]] = {
4
+ "owner": frozenset(), # owner gets implicit access to everything
5
+ "user": frozenset(
6
+ {
7
+ "identify_user",
8
+ "get_user_preference",
9
+ "set_user_preference",
10
+ "list_channel_users",
11
+ }
12
+ ),
13
+ "guest": frozenset({"identify_user"}),
14
+ }
15
+
16
+
17
+ def check_permission(role: str | None, tool_name: str) -> bool:
18
+ if not role:
19
+ return False
20
+ normalized = role.lower()
21
+ if normalized == "owner":
22
+ return True
23
+ allowed = ROLE_PERMISSIONS.get(normalized, frozenset())
24
+ return tool_name in allowed
25
+
26
+
27
+ def is_owner(role: str | None) -> bool:
28
+ return (role or "").lower() == "owner"
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ ToolSchema = dict
4
+
5
+ IDENTIFY_USER: ToolSchema = {
6
+ "name": "identify_user",
7
+ "description": (
8
+ "Return the current user identity, their platform, display name, and role. "
9
+ "Use this when you need to make decisions that depend on who is talking."
10
+ ),
11
+ "parameters": {
12
+ "type": "object",
13
+ "properties": {},
14
+ "required": [],
15
+ },
16
+ }
17
+
18
+ GET_USER_PREFERENCE: ToolSchema = {
19
+ "name": "get_user_preference",
20
+ "description": (
21
+ "Retrieve a stored preference for the current user. "
22
+ "Preference keys can be global or scoped to a channel (pass channel_id)."
23
+ ),
24
+ "parameters": {
25
+ "type": "object",
26
+ "properties": {
27
+ "key": {
28
+ "type": "string",
29
+ "description": "Preference key, e.g. 'theme' or 'tone'.",
30
+ },
31
+ "channel_id": {
32
+ "type": "string",
33
+ "description": "Optional channel scope to lookup a channel-specific preference.",
34
+ },
35
+ },
36
+ "required": ["key"],
37
+ },
38
+ }
39
+
40
+ SET_USER_PREFERENCE: ToolSchema = {
41
+ "name": "set_user_preference",
42
+ "description": (
43
+ "Store a preference for the current user. "
44
+ "Use channel_id to make it channel-specific."
45
+ ),
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": {
49
+ "key": {"type": "string"},
50
+ "value": {"type": "string"},
51
+ "channel_id": {"type": "string"},
52
+ },
53
+ "required": ["key", "value"],
54
+ },
55
+ }
56
+
57
+ SET_CHANNEL_PREFERENCE: ToolSchema = {
58
+ "name": "set_channel_preference",
59
+ "description": (
60
+ "Owner-only: store a preference that applies to the entire channel and is visible to everyone."
61
+ ),
62
+ "parameters": {
63
+ "type": "object",
64
+ "properties": {
65
+ "channel_id": {"type": "string"},
66
+ "key": {"type": "string"},
67
+ "value": {"type": "string"},
68
+ },
69
+ "required": ["channel_id", "key", "value"],
70
+ },
71
+ }
72
+
73
+ LIST_CHANNEL_USERS: ToolSchema = {
74
+ "name": "list_channel_users",
75
+ "description": (
76
+ "Return every known member of the current channel (or the channel_id parameter). "
77
+ "Results include display_name, platform, and role."
78
+ ),
79
+ "parameters": {
80
+ "type": "object",
81
+ "properties": {
82
+ "channel_id": {"type": "string"},
83
+ },
84
+ "required": [],
85
+ },
86
+ }
87
+
88
+ PROMOTE_USER: ToolSchema = {
89
+ "name": "promote_user",
90
+ "description": (
91
+ "Owner-only: promote a channel member to the 'user' role. "
92
+ "Provide platform and platform_user_id so we can find the right record."
93
+ ),
94
+ "parameters": {
95
+ "type": "object",
96
+ "properties": {
97
+ "platform": {"type": "string"},
98
+ "platform_user_id": {"type": "string"},
99
+ },
100
+ "required": ["platform", "platform_user_id"],
101
+ },
102
+ }
103
+
104
+ DEMOTE_USER: ToolSchema = {
105
+ "name": "demote_user",
106
+ "description": (
107
+ "Owner-only: demote a channel member to 'guest'. "
108
+ "Use the same arguments as promote_user."
109
+ ),
110
+ "parameters": PROMOTE_USER["parameters"],
111
+ }
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from threading import Lock
6
+ from typing import Iterable
7
+
8
+
9
+ def _ensure_path(path: Path) -> None:
10
+ path.parent.mkdir(parents=True, exist_ok=True)
11
+
12
+
13
+ class Storage:
14
+ def __init__(self, db_path: Path | str | None = None) -> None:
15
+ if db_path is None:
16
+ db_path = Path.home() / ".hermes" / "plugins" / "multi-user-rbac" / "data" / "users.db"
17
+ self._db_path = Path(db_path)
18
+ _ensure_path(self._db_path)
19
+ self._lock = Lock()
20
+ self._conn = sqlite3.connect(
21
+ str(self._db_path),
22
+ check_same_thread=False,
23
+ isolation_level=None,
24
+ )
25
+ self._conn.row_factory = sqlite3.Row
26
+ with self._lock:
27
+ self._conn.execute("PRAGMA journal_mode = WAL;")
28
+ self._conn.execute("PRAGMA foreign_keys = ON;")
29
+ self._ensure_tables()
30
+
31
+ def _ensure_tables(self) -> None:
32
+ with self._lock:
33
+ self._conn.executescript(
34
+ """
35
+ CREATE TABLE IF NOT EXISTS users (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ platform TEXT NOT NULL,
38
+ platform_user_id TEXT NOT NULL,
39
+ display_name TEXT NOT NULL,
40
+ role TEXT NOT NULL DEFAULT 'guest',
41
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
42
+ UNIQUE(platform, platform_user_id)
43
+ );
44
+ CREATE TABLE IF NOT EXISTS channel_members (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ channel_id TEXT NOT NULL,
47
+ platform TEXT NOT NULL,
48
+ platform_user_id TEXT NOT NULL,
49
+ UNIQUE(channel_id, platform, platform_user_id)
50
+ );
51
+ CREATE TABLE IF NOT EXISTS preferences (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ user_id INTEGER NOT NULL,
54
+ channel_id TEXT,
55
+ key TEXT NOT NULL,
56
+ value TEXT NOT NULL,
57
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
58
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
59
+ UNIQUE(user_id, channel_id, key)
60
+ );
61
+ CREATE TABLE IF NOT EXISTS channel_preferences (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ channel_id TEXT NOT NULL,
64
+ key TEXT NOT NULL,
65
+ value TEXT NOT NULL,
66
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
67
+ UNIQUE(channel_id, key)
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_users_platform_user ON users(platform, platform_user_id);
70
+ CREATE INDEX IF NOT EXISTS idx_preferences_user ON preferences(user_id);
71
+ CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
72
+ CREATE INDEX IF NOT EXISTS idx_channel_preferences_channel ON channel_preferences(channel_id);
73
+ """
74
+ )
75
+
76
+ def _fetchone(self, query: str, params: Iterable | None = None) -> sqlite3.Row | None:
77
+ with self._lock:
78
+ cur = self._conn.execute(query, tuple(params) if params else ())
79
+ return cur.fetchone()
80
+
81
+ def _fetchall(self, query: str, params: Iterable | None = None) -> list[sqlite3.Row]:
82
+ with self._lock:
83
+ cur = self._conn.execute(query, tuple(params) if params else ())
84
+ return cur.fetchall()
85
+
86
+ def _execute(self, query: str, params: Iterable | None = None) -> None:
87
+ with self._lock:
88
+ self._conn.execute(query, tuple(params) if params else ())
89
+
90
+ def get_or_create_user(
91
+ self,
92
+ platform: str,
93
+ platform_user_id: str,
94
+ display_name: str,
95
+ default_role: str = "guest",
96
+ ) -> dict:
97
+ user = self.get_user_by_platform(platform, platform_user_id)
98
+ if user:
99
+ if user["display_name"] != display_name:
100
+ self.update_display_name(user["id"], display_name)
101
+ user = self.get_user_by_id(user["id"])
102
+ return user
103
+ self._execute(
104
+ "INSERT INTO users (platform, platform_user_id, display_name, role) VALUES (?, ?, ?, ?)" ,
105
+ (platform, platform_user_id, display_name, default_role),
106
+ )
107
+ return self.get_user_by_platform(platform, platform_user_id)
108
+
109
+ def get_user_by_id(self, user_id: int) -> dict | None:
110
+ row = self._fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
111
+ return dict(row) if row else None
112
+
113
+ def get_user_by_platform(self, platform: str, platform_user_id: str) -> dict | None:
114
+ row = self._fetchone(
115
+ "SELECT * FROM users WHERE platform = ? AND platform_user_id = ?",
116
+ (platform, platform_user_id),
117
+ )
118
+ return dict(row) if row else None
119
+
120
+ def update_display_name(self, user_id: int, display_name: str) -> None:
121
+ self._execute(
122
+ "UPDATE users SET display_name = ? WHERE id = ?", (display_name, user_id)
123
+ )
124
+
125
+ def set_role(self, user_id: int, role: str) -> None:
126
+ self._execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
127
+
128
+ def record_channel_membership(
129
+ self, channel_id: str, platform: str, platform_user_id: str
130
+ ) -> None:
131
+ self._execute(
132
+ "INSERT OR IGNORE INTO channel_members (channel_id, platform, platform_user_id) VALUES (?, ?, ?)",
133
+ (channel_id, platform, platform_user_id),
134
+ )
135
+
136
+ def list_channel_users(self, channel_id: str, platform: str | None = None) -> list[dict]:
137
+ if platform:
138
+ rows = self._fetchall(
139
+ "SELECT u.* FROM channel_members cm JOIN users u ON cm.platform = u.platform AND cm.platform_user_id = u.platform_user_id WHERE cm.channel_id = ? AND cm.platform = ?",
140
+ (channel_id, platform),
141
+ )
142
+ else:
143
+ rows = self._fetchall(
144
+ "SELECT u.* FROM channel_members cm JOIN users u ON cm.platform = u.platform AND cm.platform_user_id = u.platform_user_id WHERE cm.channel_id = ?",
145
+ (channel_id,),
146
+ )
147
+ return [dict(row) for row in rows]
148
+
149
+ def set_preference(
150
+ self,
151
+ user_id: int,
152
+ key: str,
153
+ value: str,
154
+ channel_id: str | None = None,
155
+ ) -> None:
156
+ self._execute(
157
+ "INSERT INTO preferences (user_id, channel_id, key, value) VALUES (?, ?, ?, ?)"
158
+ " ON CONFLICT(user_id, channel_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
159
+ (user_id, channel_id, key, value),
160
+ )
161
+
162
+ def get_preference(
163
+ self,
164
+ user_id: int,
165
+ key: str,
166
+ channel_id: str | None = None,
167
+ ) -> dict | None:
168
+ row = self._fetchone(
169
+ "SELECT * FROM preferences WHERE user_id = ? AND key = ? AND channel_id IS ?",
170
+ (user_id, key, channel_id),
171
+ )
172
+ return dict(row) if row else None
173
+
174
+ def get_recent_preferences(self, user_id: int, limit: int = 3) -> list[str]:
175
+ rows = self._fetchall(
176
+ "SELECT key FROM preferences WHERE user_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?",
177
+ (user_id, limit),
178
+ )
179
+ return [row["key"] for row in rows]
180
+
181
+ def set_channel_preference(self, channel_id: str, key: str, value: str) -> None:
182
+ self._execute(
183
+ "INSERT INTO channel_preferences (channel_id, key, value) VALUES (?, ?, ?)"
184
+ " ON CONFLICT(channel_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
185
+ (channel_id, key, value),
186
+ )
187
+
188
+ def get_channel_preferences(self, channel_id: str) -> dict[str, str]:
189
+ rows = self._fetchall(
190
+ "SELECT key, value FROM channel_preferences WHERE channel_id = ?",
191
+ (channel_id,),
192
+ )
193
+ return {row["key"]: row["value"] for row in rows}
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .config import ensure_config_dir, is_owner as config_is_owner, load_owner_config
9
+ from .permissions import check_permission, is_owner as role_is_owner
10
+ from .storage import Storage
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ ensure_config_dir()
15
+ _storage = Storage()
16
+
17
+
18
+ @dataclass
19
+ class PluginContext:
20
+ session_id: str | None = None
21
+ platform: str | None = None
22
+ channel_id: str | None = None
23
+ current_user: dict | None = None
24
+
25
+
26
+ _plugin_ctx = PluginContext()
27
+
28
+
29
+ def _error(message: str) -> str:
30
+ return json.dumps({"error": message})
31
+
32
+
33
+ def _require_current_user() -> dict | str:
34
+ user = _plugin_ctx.current_user
35
+ if not user:
36
+ return _error("No user identified in this session")
37
+ return user
38
+
39
+
40
+ def identify_user(args: dict, **kwargs: Any) -> str:
41
+ user = _require_current_user()
42
+ if isinstance(user, str):
43
+ return user
44
+ return json.dumps(
45
+ {
46
+ "platform": user.get("platform"),
47
+ "platform_user_id": user.get("platform_user_id"),
48
+ "display_name": user.get("display_name"),
49
+ "role": user.get("role"),
50
+ }
51
+ )
52
+
53
+
54
+ def get_user_preference(args: dict, **kwargs: Any) -> str:
55
+ user = _require_current_user()
56
+ if isinstance(user, str):
57
+ return user
58
+ key = (args or {}).get("key")
59
+ if not key:
60
+ return _error("key required")
61
+ channel_id = args.get("channel_id") if args else None
62
+ pref = _storage.get_preference(user["id"], key, channel_id)
63
+ if not pref and channel_id:
64
+ pref = _storage.get_preference(user["id"], key, None)
65
+ if not pref:
66
+ return _error(f"{key} not set")
67
+ return json.dumps(
68
+ {
69
+ "status": "ok",
70
+ "key": pref["key"],
71
+ "value": pref["value"],
72
+ "channel_id": pref["channel_id"],
73
+ }
74
+ )
75
+
76
+
77
+ def set_user_preference(args: dict, **kwargs: Any) -> str:
78
+ user = _require_current_user()
79
+ if isinstance(user, str):
80
+ return user
81
+ key = (args or {}).get("key")
82
+ value = (args or {}).get("value")
83
+ if not key or value is None:
84
+ return _error("key and value required")
85
+ channel_id = args.get("channel_id") if args else None
86
+ _storage.set_preference(user["id"], key, str(value), channel_id)
87
+ return json.dumps({"status": "ok", "key": key, "value": value, "channel_id": channel_id})
88
+
89
+
90
+ def set_channel_preference(args: dict, **kwargs: Any) -> str:
91
+ user = _require_current_user()
92
+ if isinstance(user, str):
93
+ return user
94
+ if not role_is_owner(user.get("role")):
95
+ return _error("Only owner can set channel preferences")
96
+ channel_id = args.get("channel_id")
97
+ key = args.get("key")
98
+ value = args.get("value")
99
+ if not channel_id or not key or value is None:
100
+ return _error("channel_id, key, value required")
101
+ _storage.set_channel_preference(channel_id, key, str(value))
102
+ return json.dumps({"status": "ok", "channel_id": channel_id, "key": key, "value": value})
103
+
104
+
105
+ def list_channel_users(args: dict, **kwargs: Any) -> str:
106
+ user = _require_current_user()
107
+ if isinstance(user, str):
108
+ return user
109
+ channel_id = (args or {}).get("channel_id") or _plugin_ctx.channel_id
110
+ if not channel_id:
111
+ return _error("No channel ID known; specify channel_id")
112
+ users = _storage.list_channel_users(channel_id, _plugin_ctx.platform)
113
+ serialized = [
114
+ {
115
+ "display_name": u["display_name"],
116
+ "role": u["role"],
117
+ "platform": u["platform"],
118
+ "platform_user_id": u["platform_user_id"],
119
+ }
120
+ for u in users
121
+ ]
122
+ return json.dumps({"users": serialized, "count": len(serialized)})
123
+
124
+
125
+ def _change_role(
126
+ target_platform: str | None,
127
+ target_user_id: str | None,
128
+ new_role: str,
129
+ ) -> dict | str:
130
+ resolved_platform = target_platform or _plugin_ctx.platform
131
+ if not resolved_platform or not target_user_id:
132
+ return _error("platform and platform_user_id required")
133
+ target = _storage.get_user_by_platform(resolved_platform, target_user_id)
134
+ if not target:
135
+ return _error("User not found. They need to interact first.")
136
+ _storage.set_role(target["id"], new_role)
137
+ return {"status": new_role, "user": target["display_name"], "role": new_role}
138
+
139
+
140
+ def promote_user(args: dict, **kwargs: Any) -> str:
141
+ user = _require_current_user()
142
+ if isinstance(user, str):
143
+ return user
144
+ if not role_is_owner(user.get("role")):
145
+ return _error("Only owner can promote users")
146
+ result = _change_role(args.get("platform"), args.get("platform_user_id"), "user")
147
+ if isinstance(result, str):
148
+ return result
149
+ return json.dumps(result)
150
+
151
+
152
+ def demote_user(args: dict, **kwargs: Any) -> str:
153
+ user = _require_current_user()
154
+ if isinstance(user, str):
155
+ return user
156
+ if not role_is_owner(user.get("role")):
157
+ return _error("Only owner can demote users")
158
+ result = _change_role(args.get("platform"), args.get("platform_user_id"), "guest")
159
+ if isinstance(result, str):
160
+ return result
161
+ return json.dumps(result)
162
+
163
+
164
+ def _extract_metadata(kwargs: dict[str, Any]) -> dict[str, Any]:
165
+ for key in ("metadata", "session_metadata", "gateway_metadata", "context", "env"):
166
+ candidate = kwargs.get(key)
167
+ if isinstance(candidate, dict):
168
+ return candidate
169
+ return {}
170
+
171
+
172
+ def _identify_sender(platform: str | None, kwargs: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
173
+ meta = _extract_metadata(kwargs)
174
+ platform_user_id = (
175
+ kwargs.get("platform_user_id")
176
+ or kwargs.get("sender_id")
177
+ or meta.get("platform_user_id")
178
+ or meta.get("user_id")
179
+ or meta.get("sender_id")
180
+ or meta.get("user")
181
+ )
182
+ display_name = (
183
+ meta.get("display_name")
184
+ or meta.get("sender_display_name")
185
+ or meta.get("username")
186
+ or meta.get("name")
187
+ )
188
+ channel_id = (
189
+ kwargs.get("channel_id")
190
+ or meta.get("channel_id")
191
+ or meta.get("chat_id")
192
+ or meta.get("room_id")
193
+ or meta.get("conversation_id")
194
+ )
195
+ return platform_user_id, display_name, channel_id
196
+
197
+
198
+ def on_session_start(session_id: str, model: str, platform: str, **kwargs: Any) -> None:
199
+ platform_user_id, display_name, channel_id = _identify_sender(platform, kwargs)
200
+ if not platform or not platform_user_id:
201
+ _plugin_ctx.current_user = None
202
+ return
203
+ display_name = display_name or f"{platform_user_id}"
204
+ user = _storage.get_or_create_user(
205
+ platform=platform,
206
+ platform_user_id=str(platform_user_id),
207
+ display_name=display_name,
208
+ default_role="guest",
209
+ )
210
+ owners = load_owner_config()
211
+ if config_is_owner(platform, platform_user_id, owners):
212
+ _storage.set_role(user["id"], "owner")
213
+ user = _storage.get_user_by_id(user["id"])
214
+ if channel_id:
215
+ _storage.record_channel_membership(channel_id, platform, str(platform_user_id))
216
+ _plugin_ctx.session_id = session_id
217
+ _plugin_ctx.platform = platform
218
+ _plugin_ctx.channel_id = channel_id
219
+ _plugin_ctx.current_user = user
220
+ logger.debug(
221
+ "[%s] session user identified: %s (role=%s)",
222
+ session_id,
223
+ user.get("display_name"),
224
+ user.get("role"),
225
+ )
226
+
227
+
228
+ def on_pre_llm_call(
229
+ session_id: str,
230
+ user_message: str,
231
+ conversation_history: list[Any],
232
+ is_first_turn: bool,
233
+ model: str,
234
+ platform: str,
235
+ **kwargs: Any,
236
+ ) -> dict[str, str] | str | None:
237
+ user = _plugin_ctx.current_user
238
+ if not user:
239
+ return None
240
+ pref_keys = _storage.get_recent_preferences(user["id"], limit=3)
241
+ channel_prefs = (
242
+ _storage.get_channel_preferences(_plugin_ctx.channel_id)
243
+ if _plugin_ctx.channel_id
244
+ else {}
245
+ )
246
+ pref_str = ", ".join(pref_keys) if pref_keys else "(none)"
247
+ channel_pref_text = (
248
+ ", ".join(f"{k}={v}" for k, v in channel_prefs.items())
249
+ if channel_prefs
250
+ else ""
251
+ )
252
+ context_parts = [
253
+ f"[MultiUserRBAC] User: {user['display_name']} (role={user['role']})",
254
+ f"Active prefs: {pref_str}",
255
+ ]
256
+ if channel_pref_text:
257
+ context_parts.append(f"Channel prefs: {channel_pref_text}")
258
+ return {"context": " | ".join(context_parts)}
259
+
260
+
261
+ def on_post_tool_call(
262
+ tool_name: str, args: dict[str, Any], result: str, task_id: str, **kwargs: Any
263
+ ) -> None:
264
+ user = _plugin_ctx.current_user
265
+ if not user:
266
+ return
267
+ if not check_permission(user.get("role"), tool_name):
268
+ logger.warning(
269
+ "Role %s tried to call %s; denying in post hook (session %s)",
270
+ user.get("role"),
271
+ tool_name,
272
+ task_id,
273
+ )
274
+ logger.debug(
275
+ "Post tool call: %s args=%s user=%s", tool_name, args, user.get("display_name")
276
+ )
277
+
278
+
279
+ def on_session_finalize(session_id: str | None, platform: str, **kwargs: Any) -> None:
280
+ _plugin_ctx.current_user = None
281
+ _plugin_ctx.channel_id = None
282
+ _plugin_ctx.platform = None
283
+ _plugin_ctx.session_id = None
284
+
285
+
286
+ # Slash command helpers
287
+ def _require_owner_or_error() -> str | None:
288
+ user = _plugin_ctx.current_user
289
+ if not user:
290
+ return "No current user"
291
+ if not role_is_owner(user.get("role")):
292
+ return "Only owner can run this command"
293
+ return None
294
+
295
+
296
+ def _resolve_platform_and_id(raw_args: str) -> tuple[str | None, str | None, str]:
297
+ parts = raw_args.strip().split()
298
+ if len(parts) == 1:
299
+ platform = _plugin_ctx.platform
300
+ if not platform:
301
+ return None, None, "Specify platform and platform_user_id"
302
+ return platform, parts[0], ""
303
+ if len(parts) == 2:
304
+ return parts[0], parts[1], ""
305
+ return None, None, "Usage: <platform_user_id> or <platform> <platform_user_id>"
306
+
307
+
308
+ def _extract_error(payload: str) -> str:
309
+ try:
310
+ data = json.loads(payload)
311
+ return data.get("error", payload)
312
+ except Exception:
313
+ return payload
314
+
315
+
316
+ def cmd_users(raw_args: str) -> str:
317
+ if err := _require_owner_or_error():
318
+ return err
319
+ channel_id = _plugin_ctx.channel_id
320
+ if not channel_id:
321
+ return "No channel ID known; provide a channel_id"
322
+ users = _storage.list_channel_users(channel_id, _plugin_ctx.platform)
323
+ lines = [f"{u['display_name']} ({u['role']})" for u in users]
324
+ return f"Channel members ({len(lines)}):\n" + "\n".join(lines)
325
+
326
+
327
+ def cmd_promote(raw_args: str) -> str:
328
+ if err := _require_owner_or_error():
329
+ return err
330
+ platform, target_id, usage = _resolve_platform_and_id(raw_args)
331
+ if usage:
332
+ return usage
333
+ result = _change_role(platform, target_id, "user")
334
+ if isinstance(result, str):
335
+ return _extract_error(result)
336
+ return f"Promoted {result['user']} to {result['role']}"
337
+
338
+
339
+ def cmd_demote(raw_args: str) -> str:
340
+ if err := _require_owner_or_error():
341
+ return err
342
+ platform, target_id, usage = _resolve_platform_and_id(raw_args)
343
+ if usage:
344
+ return usage
345
+ result = _change_role(platform, target_id, "guest")
346
+ if isinstance(result, str):
347
+ return _extract_error(result)
348
+ return f"Demoted {result['user']} to {result['role']}"
349
+
350
+
351
+ def cmd_set_preference_for(raw_args: str) -> str:
352
+ if err := _require_owner_or_error():
353
+ return err
354
+ parts = raw_args.strip().split(maxsplit=2)
355
+ if len(parts) < 3:
356
+ return "Usage: /set-for <platform_user_id> <key> <value>"
357
+ target_id, key, value = parts
358
+ platform = _plugin_ctx.platform
359
+ if not platform:
360
+ return "Cannot determine platform"
361
+ target = _storage.get_user_by_platform(platform, target_id)
362
+ if not target:
363
+ return f"Unknown user {target_id}"
364
+ _storage.set_preference(target["id"], key, value)
365
+ return f"Set {target['display_name']} {key}={value}"
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi-user-rbac
3
+ Version: 0.1.1
4
+ Summary: Hermes plugin implementing multi-user RBAC, preferences, and owner controls.
5
+ Author: Ahsan Athallah
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/
8
+ Project-URL: Repository, https://github.com/
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: pyyaml>=6.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: build; extra == "dev"
15
+
16
+ # Multi-user RBAC for Hermes
17
+
18
+ This plugin keeps per-platform, per-channel roles and preferences so Hermes can enforce owner/user/guest permissions and personalize responses. It stores all state in `~/.hermes/plugins/multi-user-rbac/data/users.db` and wires hooks/tools/commands following the Hermes plugin guide.
19
+
20
+ ## Installation
21
+ 1. Copy this directory to `~/.hermes/plugins/multi-user-rbac/`.
22
+ 2. Optional: install the Python dependencies (Hermes already bundles `sqlite3`, but you need PyYAML for owner configuration):
23
+ ```bash
24
+ pip install pyyaml
25
+ ```
26
+ 3. Restart Hermes. You should see `multi-user-rbac` in `/plugins` and a new set of tools/commands.
27
+
28
+ ## Configuration
29
+ Create `~/.hermes/plugins/multi-user-rbac/config.yaml` to tell the plugin who the owners are:
30
+
31
+ ```yaml
32
+ owner:
33
+ telegram:
34
+ - 123456789 # replace with your Telegram ID
35
+ discord:
36
+ - 987654321 # replace with your Discord ID
37
+ ```
38
+
39
+ Owner IDs are promoted automatically on first interaction. No config file means everyone starts as `guest`.
40
+
41
+ ## Tools
42
+ | Tool | Description | Role access |
43
+ | --- | --- | --- |
44
+ | `identify_user` | Returns the current session user identity (platform, display name, role). | Everyone |
45
+ | `get_user_preference` | Reads a preference (global or channel-scoped) for the caller. | Owner/User |
46
+ | `set_user_preference` | Stores a preference for the caller (optional `channel_id`). | Owner/User |
47
+ | `set_channel_preference` | Owners set channel-wide defaults. | Owner only |
48
+ | `list_channel_users` | Lists channel members and their roles; auto-refreshes membership when users join. | Owner/User |
49
+ | `promote_user` / `demote_user` | Owner-only tools to change another member’s role. | Owner only |
50
+
51
+ ## Hooks
52
+ - `on_session_start`: identifies who is speaking, auto-promotes owners, records channel membership.
53
+ - `pre_llm_call`: injects a short summary of the current user + recent preferences into every turn.
54
+ - `post_tool_call`: logs all tool usage, records denied access attempts for auditing.
55
+ - `on_session_finalize`: clears per-session context so new sessions start clean.
56
+
57
+ ## Commands
58
+ - `/users`: owner-only list of channel members (mirrors `list_channel_users`).
59
+ - `/promote <platform_user_id>`: owner-only; promotes the chosen member to `user`.
60
+ - `/demote <platform_user_id>`: owner-only; demotes the chosen member to `guest`.
61
+ - `/set-for <platform_user_id> <key> <value>`: owner-only; set preferences on behalf of someone else.
62
+
63
+ ## Testing
64
+ Run the bundled tests with `pytest tests`. The suite covers storage, permission checks, and the tool handlers so you can safely refactor the RBAC rules.
65
+
66
+ ## Packaging & release
67
+ 1. Run `./build_release.sh` (or `python3 -m build --outdir dist`) from the plugin root; a wheel and sdist land in `dist/`.
68
+ 2. Install the release locally with `pip install dist/multi_user_rbac-0.1.0-py3-none-any.whl` or publish `dist/` to PyPI/Nexus.
69
+ 3. Hermes auto-discovers the plugin when `multi-user-rbac` is installed via an entry point (`[project.entry-points."hermes_agent.plugins"]` in `pyproject.toml`). Restart Hermes after installation.
70
+
71
+ ## Next steps
72
+ 1. Wire the plugin into your agent by placing it under `~/.hermes/plugins/` and restarting Hermes.
73
+ 2. Keep `config.yaml` in sync with the owners you trust; the plugin auto-promotes them on first use.
74
+ 3. Extend tools/commands if you need more automation (e.g., CLI commands or skills).
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ multi_user_rbac/__init__.py
4
+ multi_user_rbac/config.py
5
+ multi_user_rbac/permissions.py
6
+ multi_user_rbac/schemas.py
7
+ multi_user_rbac/storage.py
8
+ multi_user_rbac/tools.py
9
+ multi_user_rbac.egg-info/PKG-INFO
10
+ multi_user_rbac.egg-info/SOURCES.txt
11
+ multi_user_rbac.egg-info/dependency_links.txt
12
+ multi_user_rbac.egg-info/entry_points.txt
13
+ multi_user_rbac.egg-info/requires.txt
14
+ multi_user_rbac.egg-info/top_level.txt
15
+ tests/test_permissions.py
16
+ tests/test_storage.py
17
+ tests/test_tools.py
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ multi-user-rbac = multi_user_rbac
@@ -0,0 +1,5 @@
1
+ pyyaml>=6.0
2
+
3
+ [dev]
4
+ pytest
5
+ build
@@ -0,0 +1 @@
1
+ multi_user_rbac
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=70.0", "wheel", "build"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "multi-user-rbac"
7
+ version = "0.1.1"
8
+ description = "Hermes plugin implementing multi-user RBAC, preferences, and owner controls."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [ { name = "Ahsan Athallah" } ]
12
+ license = { text = "MIT" }
13
+ dependencies = ["pyyaml>=6.0"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/"
17
+ Repository = "https://github.com/"
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest", "build"]
21
+
22
+ [project.entry-points."hermes_agent.plugins"]
23
+ multi-user-rbac = "multi_user_rbac"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ from multi_user_rbac.permissions import check_permission, is_owner
2
+
3
+
4
+ def test_owner_has_full_access() -> None:
5
+ assert check_permission("owner", "anything")
6
+ assert is_owner("owner")
7
+
8
+
9
+ def test_user_only_safe_tools() -> None:
10
+ assert check_permission("user", "identify_user")
11
+ assert not check_permission("user", "promote_user")
12
+
13
+
14
+ def test_guest_limited_access() -> None:
15
+ assert check_permission("guest", "identify_user")
16
+ assert not check_permission("guest", "set_user_preference")
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+ from tempfile import TemporaryDirectory
3
+
4
+ from multi_user_rbac.storage import Storage
5
+
6
+
7
+ def test_preferences_and_channel_membership(tmp_path: Path) -> None:
8
+ db_path = tmp_path / "data.db"
9
+ storage = Storage(db_path)
10
+ user = storage.get_or_create_user("test", "user1", "User One")
11
+ storage.set_preference(user["id"], "theme", "dark")
12
+ assert storage.get_preference(user["id"], "theme")["value"] == "dark"
13
+ storage.set_preference(user["id"], "tone", "friendly", channel_id="chanA")
14
+ assert storage.get_recent_preferences(user["id"], limit=2) == ["tone", "theme"]
15
+ storage.set_channel_preference("chanA", "mode", "quiet")
16
+ assert storage.get_channel_preferences("chanA")["mode"] == "quiet"
17
+ storage.record_channel_membership("chanA", "test", "user1")
18
+ members = storage.list_channel_users("chanA", "test")
19
+ assert members and members[0]["platform_user_id"] == "user1"
20
+
21
+
22
+ def test_channel_preferences_are_isolated(tmp_path: Path) -> None:
23
+ storage = Storage(tmp_path / "second.db")
24
+ storage.set_channel_preference("chanB", "tone", "calm")
25
+ assert storage.get_channel_preferences("chanB") == {"tone": "calm"}
26
+ assert storage.get_channel_preferences("chanC") == {}
@@ -0,0 +1,65 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from multi_user_rbac import tools
7
+ from multi_user_rbac.storage import Storage
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def reset_context() -> None:
12
+ tools._plugin_ctx.current_user = None
13
+ tools._plugin_ctx.platform = None
14
+ tools._plugin_ctx.channel_id = None
15
+ tools._plugin_ctx.session_id = None
16
+ yield
17
+ tools._plugin_ctx.current_user = None
18
+ tools._plugin_ctx.platform = None
19
+ tools._plugin_ctx.channel_id = None
20
+ tools._plugin_ctx.session_id = None
21
+
22
+
23
+ @pytest.fixture
24
+ def patched_storage(tmp_path: Path) -> Storage:
25
+ original = tools._storage
26
+ replacement = Storage(tmp_path / "tools.db")
27
+ tools._storage = replacement
28
+ yield replacement
29
+ tools._storage = original
30
+
31
+
32
+ def test_set_and_get_preference(patched_storage: Storage) -> None:
33
+ storage = patched_storage
34
+ user = storage.get_or_create_user("test", "userA", "Tester")
35
+ tools._plugin_ctx.current_user = user
36
+ tools._plugin_ctx.platform = "test"
37
+ response = tools.set_user_preference({"key": "theme", "value": "dark"})
38
+ assert json.loads(response)["status"] == "ok"
39
+ lookup = json.loads(tools.get_user_preference({"key": "theme"}))
40
+ assert lookup["value"] == "dark"
41
+
42
+
43
+ def test_owner_channel_preferences(patched_storage: Storage) -> None:
44
+ storage = patched_storage
45
+ owner = storage.get_or_create_user("platform", "owner1", "Owner")
46
+ storage.set_role(owner["id"], "owner")
47
+ tools._plugin_ctx.current_user = storage.get_user_by_id(owner["id"])
48
+ response = tools.set_channel_preference(
49
+ {"channel_id": "chan", "key": "mood", "value": "focused"}
50
+ )
51
+ assert json.loads(response)["status"] == "ok"
52
+ assert storage.get_channel_preferences("chan")["mood"] == "focused"
53
+
54
+
55
+ def test_cmd_promote_demote(patched_storage: Storage) -> None:
56
+ storage = patched_storage
57
+ owner = storage.get_or_create_user("test", "owner1", "Owner")
58
+ target = storage.get_or_create_user("test", "target1", "Target")
59
+ storage.set_role(owner["id"], "owner")
60
+ tools._plugin_ctx.current_user = storage.get_user_by_id(owner["id"])
61
+ tools._plugin_ctx.platform = "test"
62
+ assert "Promoted" in tools.cmd_promote("target1")
63
+ assert storage.get_user_by_platform("test", "target1")["role"] == "user"
64
+ assert "Demoted" in tools.cmd_demote("target1")
65
+ assert storage.get_user_by_platform("test", "target1")["role"] == "guest"