nonebot-plugin-onebot-luckperms 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_onebot_luckperms/__init__.py +127 -0
- nonebot_plugin_onebot_luckperms/adapter/__init__.py +17 -0
- nonebot_plugin_onebot_luckperms/adapter/context.py +31 -0
- nonebot_plugin_onebot_luckperms/adapter/identity.py +64 -0
- nonebot_plugin_onebot_luckperms/adapter/permission.py +157 -0
- nonebot_plugin_onebot_luckperms/commands/__init__.py +3 -0
- nonebot_plugin_onebot_luckperms/commands/admin.py +857 -0
- nonebot_plugin_onebot_luckperms/config.py +20 -0
- nonebot_plugin_onebot_luckperms/core/__init__.py +20 -0
- nonebot_plugin_onebot_luckperms/core/cache.py +44 -0
- nonebot_plugin_onebot_luckperms/core/context_provider.py +74 -0
- nonebot_plugin_onebot_luckperms/core/engine.py +162 -0
- nonebot_plugin_onebot_luckperms/core/exceptions.py +6 -0
- nonebot_plugin_onebot_luckperms/core/models.py +123 -0
- nonebot_plugin_onebot_luckperms/core/registry.py +72 -0
- nonebot_plugin_onebot_luckperms/message.py +126 -0
- nonebot_plugin_onebot_luckperms/py.typed +0 -0
- nonebot_plugin_onebot_luckperms/storage/__init__.py +29 -0
- nonebot_plugin_onebot_luckperms/storage/memory.py +46 -0
- nonebot_plugin_onebot_luckperms/storage/protocol.py +21 -0
- nonebot_plugin_onebot_luckperms/storage/redis.py +162 -0
- nonebot_plugin_onebot_luckperms/storage/sqlite.py +189 -0
- nonebot_plugin_onebot_luckperms/webeditor/__init__.py +9 -0
- nonebot_plugin_onebot_luckperms/webeditor/bytebin.py +71 -0
- nonebot_plugin_onebot_luckperms/webeditor/manager.py +228 -0
- nonebot_plugin_onebot_luckperms/webeditor/session.py +109 -0
- nonebot_plugin_onebot_luckperms/webeditor/websocket.py +180 -0
- nonebot_plugin_onebot_luckperms-0.1.0.dist-info/METADATA +23 -0
- nonebot_plugin_onebot_luckperms-0.1.0.dist-info/RECORD +32 -0
- nonebot_plugin_onebot_luckperms-0.1.0.dist-info/WHEEL +5 -0
- nonebot_plugin_onebot_luckperms-0.1.0.dist-info/licenses/LICENSE +21 -0
- nonebot_plugin_onebot_luckperms-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import List, Literal
|
|
2
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OBLPConfig(BaseModel):
|
|
6
|
+
model_config = ConfigDict(extra="ignore")
|
|
7
|
+
|
|
8
|
+
store_type: Literal["memory", "sqlite", "redis"] = "sqlite"
|
|
9
|
+
sqlite_path: str = "./data/oblp/permissions.db"
|
|
10
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
11
|
+
default_group_owner: List[str] = Field(default_factory=lambda: ["luckperms.help"])
|
|
12
|
+
default_group_admin: List[str] = Field(default_factory=list)
|
|
13
|
+
default_group_member: List[str] = Field(default_factory=list)
|
|
14
|
+
superuser_inherit: List[str] = Field(default_factory=lambda: ["luckperms.*"])
|
|
15
|
+
cache_ttl: int = 300
|
|
16
|
+
debug_mode: bool = False
|
|
17
|
+
message_file: str = "./data/oblp/messages.yml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
oblp_config = OBLPConfig()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .models import ContextSet, PermissionNode, User, Group, QueryOptions
|
|
2
|
+
from .engine import PermissionEngine
|
|
3
|
+
from .registry import NodeRegistry, register_node
|
|
4
|
+
from .exceptions import CircularInheritanceError, PermissionException
|
|
5
|
+
from .context_provider import register_context_provider, ContextProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ContextSet",
|
|
9
|
+
"PermissionNode",
|
|
10
|
+
"User",
|
|
11
|
+
"Group",
|
|
12
|
+
"QueryOptions",
|
|
13
|
+
"PermissionEngine",
|
|
14
|
+
"NodeRegistry",
|
|
15
|
+
"register_node",
|
|
16
|
+
"CircularInheritanceError",
|
|
17
|
+
"PermissionException",
|
|
18
|
+
"register_context_provider",
|
|
19
|
+
"ContextProvider",
|
|
20
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from ..config import oblp_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PermissionCache:
|
|
10
|
+
_cache: Dict[str, Tuple[float, dict]] = {}
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def get(cls, key: str) -> Optional[dict]:
|
|
14
|
+
ttl = oblp_config.cache_ttl
|
|
15
|
+
if ttl <= 0:
|
|
16
|
+
return None
|
|
17
|
+
entry = cls._cache.get(key)
|
|
18
|
+
if entry is None:
|
|
19
|
+
return None
|
|
20
|
+
ts, value = entry
|
|
21
|
+
if time.time() - ts > ttl:
|
|
22
|
+
del cls._cache[key]
|
|
23
|
+
return None
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def set(cls, key: str, value: dict):
|
|
28
|
+
ttl = oblp_config.cache_ttl
|
|
29
|
+
if ttl <= 0:
|
|
30
|
+
return
|
|
31
|
+
cls._cache[key] = (time.time(), value)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def invalidate(cls, key: str):
|
|
35
|
+
cls._cache.pop(key, None)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def invalidate_all(cls):
|
|
39
|
+
cls._cache.clear()
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def make_key(cls, user_id: str, ctx: "ContextSet") -> str:
|
|
43
|
+
ctx_str = "&".join(f"{k}={v}" for k, v in sorted(ctx.data.items()))
|
|
44
|
+
return f"{user_id}:{ctx_str}"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, List, Protocol
|
|
5
|
+
|
|
6
|
+
from nonebot.adapters import Bot, Event
|
|
7
|
+
|
|
8
|
+
from .models import ContextSet
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("oblp")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DuplicateProviderError(Exception):
|
|
14
|
+
"""Raised when a ContextProvider registers a key already claimed by another provider."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextProvider(Protocol):
|
|
18
|
+
async def __call__(
|
|
19
|
+
self, bot: Bot, event: Event, current_ctx: ContextSet
|
|
20
|
+
) -> Dict[str, str]:
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_context_providers: List[ContextProvider] = []
|
|
25
|
+
_claimed_keys: Dict[str, str] = {} # key → provider_identity
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _provider_identity(provider: ContextProvider) -> str:
|
|
29
|
+
return f"{type(provider).__module__}.{type(provider).__qualname__}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_context_provider(provider: ContextProvider):
|
|
33
|
+
identity = _provider_identity(provider)
|
|
34
|
+
|
|
35
|
+
# Probe which keys this provider returns by running it with an empty context
|
|
36
|
+
# (We can't call it here since it needs bot/event. Instead, require provider
|
|
37
|
+
# to declare its keys statically via a `context_keys` class attribute.)
|
|
38
|
+
|
|
39
|
+
if hasattr(provider, "context_keys"):
|
|
40
|
+
keys: set[str] = provider.context_keys
|
|
41
|
+
else:
|
|
42
|
+
keys = set()
|
|
43
|
+
|
|
44
|
+
if not keys:
|
|
45
|
+
logger.warning(
|
|
46
|
+
f"ContextProvider '{identity}' does not declare `context_keys`. "
|
|
47
|
+
f"Add a class-level `context_keys = {{\"your_key\"}}` to prevent conflicts."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
conflicts = []
|
|
51
|
+
for k in keys:
|
|
52
|
+
if k in _claimed_keys:
|
|
53
|
+
conflicts.append((k, _claimed_keys[k]))
|
|
54
|
+
|
|
55
|
+
if conflicts:
|
|
56
|
+
detail = "; ".join(f"key='{k}' already claimed by {owner}" for k, owner in conflicts)
|
|
57
|
+
raise DuplicateProviderError(
|
|
58
|
+
f"ContextProvider '{identity}' conflicts: {detail}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
for k in keys:
|
|
62
|
+
_claimed_keys[k] = identity
|
|
63
|
+
|
|
64
|
+
_context_providers.append(provider)
|
|
65
|
+
logger.info(f"ContextProvider registered: {identity} keys={keys}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_context_providers() -> List[ContextProvider]:
|
|
69
|
+
return list(_context_providers)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clear_providers():
|
|
73
|
+
_context_providers.clear()
|
|
74
|
+
_claimed_keys.clear()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from .models import User, PermissionNode, QueryOptions, ContextSet
|
|
6
|
+
from .registry import NodeRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _match_segments(pattern: str, target: str) -> bool:
|
|
10
|
+
"""Segment-based wildcard matching.
|
|
11
|
+
|
|
12
|
+
* matches zero or more segments (multi-segment wildcard).
|
|
13
|
+
** alias for *, identical behavior.
|
|
14
|
+
Literals must match exactly.
|
|
15
|
+
|
|
16
|
+
Per our spec, * matches "a.b" and also "a.b.c" (not single-segment).
|
|
17
|
+
"""
|
|
18
|
+
pat_parts = pattern.split(".")
|
|
19
|
+
tgt_parts = target.split(".")
|
|
20
|
+
return _match_dp(pat_parts, tgt_parts)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _match_dp(p: list[str], t: list[str]) -> bool:
|
|
24
|
+
n, m = len(p), len(t)
|
|
25
|
+
dp = [[False] * (m + 1) for _ in range(n + 1)]
|
|
26
|
+
dp[0][0] = True
|
|
27
|
+
for i in range(1, n + 1):
|
|
28
|
+
dp[i][0] = dp[i - 1][0] if p[i - 1] in ("*", "**") else False
|
|
29
|
+
for i in range(1, n + 1):
|
|
30
|
+
for j in range(1, m + 1):
|
|
31
|
+
if p[i - 1] in ("*", "**"):
|
|
32
|
+
dp[i][j] = dp[i - 1][j] or dp[i][j - 1]
|
|
33
|
+
elif p[i - 1] == t[j - 1]:
|
|
34
|
+
dp[i][j] = dp[i - 1][j - 1]
|
|
35
|
+
else:
|
|
36
|
+
dp[i][j] = False
|
|
37
|
+
return dp[n][m]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PermissionEngine:
|
|
41
|
+
@staticmethod
|
|
42
|
+
def check(user_nodes: Dict[str, bool], target: str) -> bool:
|
|
43
|
+
# Stage 1: Exact match (highest priority)
|
|
44
|
+
if target in user_nodes:
|
|
45
|
+
return user_nodes[target]
|
|
46
|
+
|
|
47
|
+
# Stage 2: Parent deny inheritance (security critical)
|
|
48
|
+
parts = target.split(".")
|
|
49
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
50
|
+
parent = ".".join(parts[:i])
|
|
51
|
+
if parent in user_nodes and not user_nodes[parent]:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# Stage 3: Wildcard match (segment-based)
|
|
55
|
+
for pattern, value in user_nodes.items():
|
|
56
|
+
if "*" in pattern and _match_segments(pattern, target):
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
# Stage 4: Prefix inheritance (allow only)
|
|
60
|
+
for node_key, value in user_nodes.items():
|
|
61
|
+
if value and target.startswith(node_key + "."):
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
# Stage 5: Inheritance chain via NodeRegistry
|
|
65
|
+
for ancestor in NodeRegistry.get_ancestors(target):
|
|
66
|
+
if ancestor in user_nodes:
|
|
67
|
+
return user_nodes[ancestor]
|
|
68
|
+
|
|
69
|
+
# Stage 6: Default deny
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
async def resolve_effective_nodes(
|
|
74
|
+
user: User,
|
|
75
|
+
store,
|
|
76
|
+
options: QueryOptions,
|
|
77
|
+
current_ctx: Optional[ContextSet] = None,
|
|
78
|
+
) -> Dict[str, bool]:
|
|
79
|
+
candidates: List[tuple[int, PermissionNode]] = []
|
|
80
|
+
# sort_key: user nodes = 0, group nodes = 1 + (10000 - weight)
|
|
81
|
+
# This ensures user's own nodes always beat any group-inherited node.
|
|
82
|
+
|
|
83
|
+
for node in user.nodes:
|
|
84
|
+
candidates.append((0, node))
|
|
85
|
+
|
|
86
|
+
if "include_inherited" in options.flags:
|
|
87
|
+
for group_name in user.groups:
|
|
88
|
+
group = await store.get_group(group_name)
|
|
89
|
+
if group is None:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
group_nodes = await group.get_effective_nodes(store)
|
|
93
|
+
except Exception:
|
|
94
|
+
continue
|
|
95
|
+
for node in group_nodes:
|
|
96
|
+
sort_key = 1 + (10000 - group.weight)
|
|
97
|
+
candidates.append((sort_key, node))
|
|
98
|
+
|
|
99
|
+
candidates.sort(key=lambda x: x[0])
|
|
100
|
+
|
|
101
|
+
ctx_for_match = options.contexts
|
|
102
|
+
grouped: Dict[str, List[tuple[int, PermissionNode]]] = {}
|
|
103
|
+
for sort_key, node in candidates:
|
|
104
|
+
if node.is_expired():
|
|
105
|
+
continue
|
|
106
|
+
if options.mode == "contextual" and not node.applies_in(ctx_for_match):
|
|
107
|
+
continue
|
|
108
|
+
grouped.setdefault(node.key, []).append((sort_key, node))
|
|
109
|
+
|
|
110
|
+
seen: Dict[str, bool] = {}
|
|
111
|
+
|
|
112
|
+
for key, entries in grouped.items():
|
|
113
|
+
best = None
|
|
114
|
+
best_key = None
|
|
115
|
+
best_match = -1
|
|
116
|
+
|
|
117
|
+
for sort_key, node in entries:
|
|
118
|
+
if options.mode == "contextual" and not node.contexts.is_empty():
|
|
119
|
+
match_cnt = sum(
|
|
120
|
+
1 for k in node.contexts.data
|
|
121
|
+
if ctx_for_match.data.get(k) == node.contexts.data[k]
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
match_cnt = 0
|
|
125
|
+
|
|
126
|
+
if best is None:
|
|
127
|
+
best = node
|
|
128
|
+
best_key = sort_key
|
|
129
|
+
best_match = match_cnt
|
|
130
|
+
elif sort_key < best_key:
|
|
131
|
+
best = node
|
|
132
|
+
best_key = sort_key
|
|
133
|
+
best_match = match_cnt
|
|
134
|
+
elif sort_key == best_key and match_cnt > best_match:
|
|
135
|
+
best = node
|
|
136
|
+
best_match = match_cnt
|
|
137
|
+
|
|
138
|
+
if best is not None:
|
|
139
|
+
seen[key] = best.value
|
|
140
|
+
|
|
141
|
+
# Parent deny inheritance
|
|
142
|
+
for key in list(seen.keys()):
|
|
143
|
+
if not seen[key]:
|
|
144
|
+
continue
|
|
145
|
+
parts = key.split(".")
|
|
146
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
147
|
+
parent = ".".join(parts[:i])
|
|
148
|
+
if parent in seen and not seen[parent]:
|
|
149
|
+
del seen[key]
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
# NodeRegistry defaults
|
|
153
|
+
ctx_for_defaults = current_ctx or options.contexts
|
|
154
|
+
for info in NodeRegistry.list_all():
|
|
155
|
+
key = info["key"]
|
|
156
|
+
if key in seen:
|
|
157
|
+
continue
|
|
158
|
+
default_ctx: ContextSet = info.get("contexts", ContextSet())
|
|
159
|
+
if ctx_for_defaults.matches(default_ctx):
|
|
160
|
+
seen[key] = info["default"]
|
|
161
|
+
|
|
162
|
+
return seen
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, List, Optional, Set, Literal
|
|
6
|
+
|
|
7
|
+
from .exceptions import CircularInheritanceError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ContextSet:
|
|
12
|
+
data: Dict[str, str] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
def with_context(self, key: str, value: str) -> ContextSet:
|
|
15
|
+
return ContextSet(data={**self.data, key: value})
|
|
16
|
+
|
|
17
|
+
def matches(self, requirement: ContextSet) -> bool:
|
|
18
|
+
if requirement.is_empty():
|
|
19
|
+
return True
|
|
20
|
+
for k, v in requirement.data.items():
|
|
21
|
+
if self.data.get(k) != v:
|
|
22
|
+
return False
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def is_empty(self) -> bool:
|
|
26
|
+
return len(self.data) == 0
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, d: Dict[str, str]) -> ContextSet:
|
|
30
|
+
return cls(data=dict(d))
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> Dict[str, str]:
|
|
33
|
+
return dict(self.data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PermissionNode:
|
|
38
|
+
key: str
|
|
39
|
+
value: bool = True
|
|
40
|
+
expiry: Optional[int] = None
|
|
41
|
+
contexts: ContextSet = field(default_factory=ContextSet)
|
|
42
|
+
|
|
43
|
+
def is_expired(self) -> bool:
|
|
44
|
+
if self.expiry is None:
|
|
45
|
+
return False
|
|
46
|
+
return time.time() > self.expiry
|
|
47
|
+
|
|
48
|
+
def applies_in(self, ctx: ContextSet) -> bool:
|
|
49
|
+
return ctx.matches(self.contexts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class QueryOptions:
|
|
54
|
+
mode: Literal["contextual", "all"] = "contextual"
|
|
55
|
+
contexts: ContextSet = field(default_factory=ContextSet)
|
|
56
|
+
flags: Set[str] = field(default_factory=lambda: {"include_inherited", "resolve_inheritance"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Group:
|
|
61
|
+
name: str
|
|
62
|
+
display_name: Optional[str] = None
|
|
63
|
+
weight: int = 0
|
|
64
|
+
nodes: List[PermissionNode] = field(default_factory=list)
|
|
65
|
+
parents: List[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
async def get_effective_nodes(
|
|
68
|
+
self,
|
|
69
|
+
store,
|
|
70
|
+
visited: Optional[Set[str]] = None,
|
|
71
|
+
) -> List[PermissionNode]:
|
|
72
|
+
if visited is None:
|
|
73
|
+
visited = set()
|
|
74
|
+
if self.name in visited:
|
|
75
|
+
raise CircularInheritanceError(
|
|
76
|
+
f"Circular inheritance detected: group '{self.name}' already visited"
|
|
77
|
+
)
|
|
78
|
+
visited.add(self.name)
|
|
79
|
+
|
|
80
|
+
result = list(self.nodes)
|
|
81
|
+
|
|
82
|
+
for parent_name in self.parents:
|
|
83
|
+
parent = await store.get_group(parent_name)
|
|
84
|
+
if parent is None:
|
|
85
|
+
continue
|
|
86
|
+
if parent.name in visited:
|
|
87
|
+
raise CircularInheritanceError(
|
|
88
|
+
f"Circular inheritance detected: group '{parent.name}' already visited"
|
|
89
|
+
)
|
|
90
|
+
parent_nodes = await parent.get_effective_nodes(store, visited)
|
|
91
|
+
result.extend(parent_nodes)
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class User:
|
|
98
|
+
user_id: str
|
|
99
|
+
username: Optional[str] = None
|
|
100
|
+
primary_group: Optional[str] = None
|
|
101
|
+
groups: List[str] = field(default_factory=list)
|
|
102
|
+
nodes: List[PermissionNode] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
async def get_effective_nodes(
|
|
105
|
+
self,
|
|
106
|
+
store,
|
|
107
|
+
options: QueryOptions,
|
|
108
|
+
current_ctx: Optional["ContextSet"] = None,
|
|
109
|
+
) -> Dict[str, bool]:
|
|
110
|
+
from .engine import PermissionEngine
|
|
111
|
+
return await PermissionEngine.resolve_effective_nodes(self, store, options, current_ctx)
|
|
112
|
+
|
|
113
|
+
async def has_permission(
|
|
114
|
+
self,
|
|
115
|
+
node_key: str,
|
|
116
|
+
store,
|
|
117
|
+
options: QueryOptions,
|
|
118
|
+
) -> bool:
|
|
119
|
+
from .engine import PermissionEngine
|
|
120
|
+
return PermissionEngine.check(
|
|
121
|
+
await self.get_effective_nodes(store, options),
|
|
122
|
+
node_key,
|
|
123
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .models import PermissionNode, ContextSet
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("oblp")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NodeRegistry:
|
|
12
|
+
_nodes: Dict[str, dict] = {}
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def register(
|
|
16
|
+
cls,
|
|
17
|
+
key: str,
|
|
18
|
+
description: str = "",
|
|
19
|
+
default: bool = False,
|
|
20
|
+
contexts: Optional[ContextSet] = None,
|
|
21
|
+
):
|
|
22
|
+
if key in cls._nodes:
|
|
23
|
+
existing = cls._nodes[key]
|
|
24
|
+
if existing.get("description") != description or existing.get("contexts") != (contexts or ContextSet()):
|
|
25
|
+
logger.warning(
|
|
26
|
+
f"Node '{key}' already registered with different parameters, "
|
|
27
|
+
f"keeping first registration"
|
|
28
|
+
)
|
|
29
|
+
return
|
|
30
|
+
cls._nodes[key] = {
|
|
31
|
+
"key": key,
|
|
32
|
+
"description": description,
|
|
33
|
+
"default": default,
|
|
34
|
+
"contexts": contexts or ContextSet(),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def get(cls, key: str) -> Optional[dict]:
|
|
39
|
+
return cls._nodes.get(key)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def list_all(cls) -> List[dict]:
|
|
43
|
+
return list(cls._nodes.values())
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get_ancestors(cls, key: str) -> List[str]:
|
|
47
|
+
parts = key.split(".")
|
|
48
|
+
ancestors = []
|
|
49
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
50
|
+
ancestors.append(".".join(parts[:i]))
|
|
51
|
+
return ancestors
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def get_default(cls, key: str) -> bool:
|
|
55
|
+
info = cls._nodes.get(key)
|
|
56
|
+
if info is None:
|
|
57
|
+
for pattern, val in cls._nodes.items():
|
|
58
|
+
if "*" in pattern:
|
|
59
|
+
import fnmatch
|
|
60
|
+
if fnmatch.fnmatch(key, pattern):
|
|
61
|
+
return val.get("default", False)
|
|
62
|
+
return False
|
|
63
|
+
return info.get("default", False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def register_node(
|
|
67
|
+
key: str,
|
|
68
|
+
description: str = "",
|
|
69
|
+
default: bool = False,
|
|
70
|
+
contexts: Optional[ContextSet] = None,
|
|
71
|
+
):
|
|
72
|
+
NodeRegistry.register(key, description, default, contexts)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("oblp")
|
|
10
|
+
|
|
11
|
+
_DEFAULT_MESSAGES: Dict[str, str] = {
|
|
12
|
+
"deny": "Permission denied. You do not have the required permission node.",
|
|
13
|
+
"deny_hint": "You can ask the bot administrator to grant you the required permission.",
|
|
14
|
+
"data_reloaded": "Data reloaded.",
|
|
15
|
+
"unknown_command": "Unknown command: {cmd}. Use /lp help",
|
|
16
|
+
"not_initialized": "OBLP not initialized.",
|
|
17
|
+
"user_not_found": "User {user_id} not found.",
|
|
18
|
+
"user_created": "User {user_id} created.",
|
|
19
|
+
"user_deleted": "User {user_id} deleted.",
|
|
20
|
+
"user_cloned": "User {user_id} cloned to {target}.",
|
|
21
|
+
"user_cleared": "User {user_id} permissions cleared.",
|
|
22
|
+
"user_promoted": "User {user_id} promoted to {track}.",
|
|
23
|
+
"user_demoted": "User {user_id} demoted from {track}.",
|
|
24
|
+
"perm_set": "{entity}: {action} {node}",
|
|
25
|
+
"perm_set_context": "{entity}: {action} {node} (ctx: {context})",
|
|
26
|
+
"perm_temp": "{entity}: temp {action} {node} ({duration})",
|
|
27
|
+
"perm_removed": "{entity}: removed {node}",
|
|
28
|
+
"perm_cleared": "{entity}: permissions cleared",
|
|
29
|
+
"perm_no_nodes": "No permission nodes.",
|
|
30
|
+
"perm_check_result": "{entity} check {node}: {result}",
|
|
31
|
+
"perm_check_allow": "ALLOW",
|
|
32
|
+
"perm_check_deny": "DENY",
|
|
33
|
+
"group_not_found": "Group {name} not found.",
|
|
34
|
+
"group_created": "Group {name} created (weight={weight}).",
|
|
35
|
+
"group_deleted": "Group {name} deleted.",
|
|
36
|
+
"group_already_exists": "Group {name} already exists.",
|
|
37
|
+
"group_renamed": "Group {name} renamed to {new_name}.",
|
|
38
|
+
"group_cloned": "Group {name} cloned to {new_name}.",
|
|
39
|
+
"group_weight_set": "Group {name} weight set to {weight}.",
|
|
40
|
+
"group_displayname_set": "Group {name} display name set to {display_name}.",
|
|
41
|
+
"group_cleared": "Group {name} permissions cleared.",
|
|
42
|
+
"group_no_members": "No members in group {name}.",
|
|
43
|
+
"parent_added": "Added parent {parent} to {entity}.",
|
|
44
|
+
"parent_removed": "Removed parent {parent} from {entity}.",
|
|
45
|
+
"parent_cleared": "{entity}: parents cleared",
|
|
46
|
+
"parent_no_parents": "{entity} has no parents.",
|
|
47
|
+
"primary_group_set": "User {entity} primary group set to {group}.",
|
|
48
|
+
"editor_opened": "Web Editor opened: {url}",
|
|
49
|
+
"editor_failed": "Editor failed: {error}",
|
|
50
|
+
"editor_apply_ok": "Applied edits for code: {code}",
|
|
51
|
+
"editor_apply_failed": "Apply failed: {error}",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_messages: Dict[str, str] = {}
|
|
55
|
+
_loaded_path: Optional[Path] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def load_messages(path: Optional[str] = None):
|
|
59
|
+
global _messages, _loaded_path
|
|
60
|
+
_messages = dict(_DEFAULT_MESSAGES)
|
|
61
|
+
|
|
62
|
+
if not path:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
p = Path(path)
|
|
66
|
+
|
|
67
|
+
# If file doesn't exist, try to export defaults
|
|
68
|
+
if not await asyncio.to_thread(p.exists):
|
|
69
|
+
try:
|
|
70
|
+
await _export_defaults(str(p))
|
|
71
|
+
logger.info(f"Default message file created at {path}")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.warning(f"Could not create message file at {path}: {e}")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
loop = asyncio.get_event_loop()
|
|
78
|
+
raw = await loop.run_in_executor(None, _read_file, p)
|
|
79
|
+
if not raw.strip():
|
|
80
|
+
logger.warning(f"Message file {path} is empty, using defaults")
|
|
81
|
+
return
|
|
82
|
+
custom = yaml.safe_load(raw) or {}
|
|
83
|
+
if not isinstance(custom, dict):
|
|
84
|
+
logger.warning(f"Message file {path} is not a valid YAML mapping, using defaults")
|
|
85
|
+
return
|
|
86
|
+
for k, v in custom.items():
|
|
87
|
+
if k in _messages and isinstance(v, str):
|
|
88
|
+
_messages[k] = v
|
|
89
|
+
_loaded_path = p
|
|
90
|
+
logger.info(f"Messages loaded from {path} ({len(custom)} overrides)")
|
|
91
|
+
except yaml.YAMLError as e:
|
|
92
|
+
logger.error(f"YAML parse error in {path}: {e}")
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"Failed to load message file {path}: {e}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_file(p: Path) -> str:
|
|
98
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
99
|
+
return f.read()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def msg(key: str, **kwargs) -> str:
|
|
103
|
+
template = _messages.get(key, _DEFAULT_MESSAGES.get(key, key))
|
|
104
|
+
if kwargs:
|
|
105
|
+
try:
|
|
106
|
+
return template.format(**kwargs)
|
|
107
|
+
except KeyError as e:
|
|
108
|
+
logger.warning(f"Missing placeholder {e} in message '{key}'")
|
|
109
|
+
return template
|
|
110
|
+
return template
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _export_defaults(path: str):
|
|
114
|
+
"""Write default messages to a YAML file as a template for users."""
|
|
115
|
+
p = Path(path)
|
|
116
|
+
await asyncio.to_thread(p.parent.mkdir, parents=True, exist_ok=True)
|
|
117
|
+
await asyncio.to_thread(_write_yaml, p, _DEFAULT_MESSAGES)
|
|
118
|
+
logger.info(f"Default message template exported to {path}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_yaml(p: Path, data: dict):
|
|
122
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
123
|
+
yaml.dump(
|
|
124
|
+
data,
|
|
125
|
+
f, allow_unicode=True, default_flow_style=False, sort_keys=False,
|
|
126
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from .protocol import PermissionStore
|
|
4
|
+
from .memory import MemoryStore
|
|
5
|
+
from .sqlite import SQLiteStore
|
|
6
|
+
|
|
7
|
+
_store: Optional[PermissionStore] = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_redis_store(**kwargs):
|
|
11
|
+
from .redis import RedisStore
|
|
12
|
+
return RedisStore(url=kwargs.get("redis_url", "redis://localhost:6379/0"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init_store(store_type: str, **kwargs) -> PermissionStore:
|
|
16
|
+
global _store
|
|
17
|
+
if store_type == "memory":
|
|
18
|
+
_store = MemoryStore()
|
|
19
|
+
elif store_type == "sqlite":
|
|
20
|
+
_store = SQLiteStore(db_path=kwargs.get("db_path", "./data/oblp/permissions.db"))
|
|
21
|
+
elif store_type == "redis":
|
|
22
|
+
_store = _get_redis_store(**kwargs)
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError(f"Unknown store type: {store_type}")
|
|
25
|
+
return _store
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_store() -> Optional[PermissionStore]:
|
|
29
|
+
return _store
|