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.
- multi_user_rbac-0.1.1/PKG-INFO +74 -0
- multi_user_rbac-0.1.1/README.md +59 -0
- multi_user_rbac-0.1.1/multi_user_rbac/__init__.py +66 -0
- multi_user_rbac-0.1.1/multi_user_rbac/config.py +48 -0
- multi_user_rbac-0.1.1/multi_user_rbac/permissions.py +28 -0
- multi_user_rbac-0.1.1/multi_user_rbac/schemas.py +111 -0
- multi_user_rbac-0.1.1/multi_user_rbac/storage.py +193 -0
- multi_user_rbac-0.1.1/multi_user_rbac/tools.py +365 -0
- multi_user_rbac-0.1.1/multi_user_rbac.egg-info/PKG-INFO +74 -0
- multi_user_rbac-0.1.1/multi_user_rbac.egg-info/SOURCES.txt +17 -0
- multi_user_rbac-0.1.1/multi_user_rbac.egg-info/dependency_links.txt +1 -0
- multi_user_rbac-0.1.1/multi_user_rbac.egg-info/entry_points.txt +2 -0
- multi_user_rbac-0.1.1/multi_user_rbac.egg-info/requires.txt +5 -0
- multi_user_rbac-0.1.1/multi_user_rbac.egg-info/top_level.txt +1 -0
- multi_user_rbac-0.1.1/pyproject.toml +23 -0
- multi_user_rbac-0.1.1/setup.cfg +4 -0
- multi_user_rbac-0.1.1/tests/test_permissions.py +16 -0
- multi_user_rbac-0.1.1/tests/test_storage.py +26 -0
- multi_user_rbac-0.1.1/tests/test_tools.py +65 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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"
|