qodalis-cli-server-plugin-users 1.0.0b6__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.
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/METADATA +21 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/RECORD +32 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/WHEEL +4 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/licenses/LICENSE +21 -0
- qodalis_cli_users/__init__.py +134 -0
- qodalis_cli_users/_http.py +162 -0
- qodalis_cli_users/cli_users_builder.py +385 -0
- qodalis_cli_users/controllers/__init__.py +13 -0
- qodalis_cli_users/controllers/_routing.py +25 -0
- qodalis_cli_users/controllers/audit_controller.py +40 -0
- qodalis_cli_users/controllers/groups_controller.py +109 -0
- qodalis_cli_users/controllers/sessions_controller.py +114 -0
- qodalis_cli_users/controllers/users_controller.py +183 -0
- qodalis_cli_users/errors.py +118 -0
- qodalis_cli_users/middleware/__init__.py +5 -0
- qodalis_cli_users/middleware/auth_middleware.py +77 -0
- qodalis_cli_users/models.py +282 -0
- qodalis_cli_users/permissions.py +58 -0
- qodalis_cli_users/server_protection.py +209 -0
- qodalis_cli_users/services/__init__.py +6 -0
- qodalis_cli_users/services/auth_service.py +786 -0
- qodalis_cli_users/services/password_hasher.py +98 -0
- qodalis_cli_users/services/users_service.py +330 -0
- qodalis_cli_users/shell_access.py +125 -0
- qodalis_cli_users/stores/__init__.py +27 -0
- qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
- qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
- qodalis_cli_users/stores/in_memory_group_store.py +65 -0
- qodalis_cli_users/stores/in_memory_session_store.py +71 -0
- qodalis_cli_users/stores/in_memory_user_store.py +102 -0
- qodalis_cli_users/stores/interfaces.py +208 -0
- qodalis_cli_users/validation.py +100 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""In-memory implementation of IGroupStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from ..models import Group, now_ms
|
|
8
|
+
from .interfaces import IGroupStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryGroupStore(IGroupStore):
|
|
12
|
+
"""Groups keyed by lower-cased name; the name is stored as given."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._groups: dict[str, Group] = {}
|
|
16
|
+
|
|
17
|
+
async def get_groups(self) -> list[Group]:
|
|
18
|
+
return [copy.deepcopy(g) for g in self._groups.values()]
|
|
19
|
+
|
|
20
|
+
async def get_group(self, name: str) -> Group | None:
|
|
21
|
+
group = self._groups.get(name.lower())
|
|
22
|
+
return copy.deepcopy(group) if group else None
|
|
23
|
+
|
|
24
|
+
async def create_group(
|
|
25
|
+
self,
|
|
26
|
+
name: str,
|
|
27
|
+
description: str | None = None,
|
|
28
|
+
permissions: list[str] | None = None,
|
|
29
|
+
*,
|
|
30
|
+
built_in: bool = False,
|
|
31
|
+
) -> Group:
|
|
32
|
+
key = name.lower()
|
|
33
|
+
if key in self._groups:
|
|
34
|
+
raise ValueError(f"group already exists: {name}")
|
|
35
|
+
now = now_ms()
|
|
36
|
+
group = Group(
|
|
37
|
+
name=name,
|
|
38
|
+
description=description,
|
|
39
|
+
permissions=list(permissions or []),
|
|
40
|
+
built_in=built_in,
|
|
41
|
+
created_at=now,
|
|
42
|
+
updated_at=now,
|
|
43
|
+
)
|
|
44
|
+
self._groups[key] = group
|
|
45
|
+
return copy.deepcopy(group)
|
|
46
|
+
|
|
47
|
+
async def update_group(
|
|
48
|
+
self,
|
|
49
|
+
name: str,
|
|
50
|
+
*,
|
|
51
|
+
description: str | None = None,
|
|
52
|
+
permissions: list[str] | None = None,
|
|
53
|
+
) -> Group:
|
|
54
|
+
group = self._groups.get(name.lower())
|
|
55
|
+
if not group:
|
|
56
|
+
raise KeyError(f"group not found: {name}")
|
|
57
|
+
if description is not None:
|
|
58
|
+
group.description = description
|
|
59
|
+
if permissions is not None:
|
|
60
|
+
group.permissions = list(permissions)
|
|
61
|
+
group.updated_at = now_ms()
|
|
62
|
+
return copy.deepcopy(group)
|
|
63
|
+
|
|
64
|
+
async def delete_group(self, name: str) -> None:
|
|
65
|
+
self._groups.pop(name.lower(), None)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""In-memory implementation of ISessionStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from ..models import Session, SessionMetadata, now_ms
|
|
8
|
+
from .interfaces import ISessionStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemorySessionStore(ISessionStore):
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._sessions: dict[str, Session] = {} # keyed by token hash
|
|
14
|
+
|
|
15
|
+
async def create_session(
|
|
16
|
+
self,
|
|
17
|
+
user_id: str,
|
|
18
|
+
token_hash: str,
|
|
19
|
+
metadata: SessionMetadata,
|
|
20
|
+
*,
|
|
21
|
+
user_name: str = "",
|
|
22
|
+
) -> Session:
|
|
23
|
+
now = now_ms()
|
|
24
|
+
session = Session(
|
|
25
|
+
token_hash=token_hash,
|
|
26
|
+
user_id=user_id,
|
|
27
|
+
user_name=user_name,
|
|
28
|
+
created_at=now,
|
|
29
|
+
last_activity=now,
|
|
30
|
+
metadata=metadata or SessionMetadata(),
|
|
31
|
+
)
|
|
32
|
+
self._sessions[token_hash] = session
|
|
33
|
+
return copy.deepcopy(session)
|
|
34
|
+
|
|
35
|
+
async def get_session(self, token_hash: str) -> Session | None:
|
|
36
|
+
session = self._sessions.get(token_hash)
|
|
37
|
+
return copy.deepcopy(session) if session else None
|
|
38
|
+
|
|
39
|
+
async def get_session_by_id(self, session_id: str) -> Session | None:
|
|
40
|
+
for session in self._sessions.values():
|
|
41
|
+
if session.id == session_id:
|
|
42
|
+
return copy.deepcopy(session)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
async def get_active_sessions(self) -> list[Session]:
|
|
46
|
+
return [copy.deepcopy(s) for s in self._sessions.values()]
|
|
47
|
+
|
|
48
|
+
async def delete_session(self, token_hash: str) -> None:
|
|
49
|
+
self._sessions.pop(token_hash, None)
|
|
50
|
+
|
|
51
|
+
async def delete_session_by_id(self, session_id: str) -> None:
|
|
52
|
+
for token, session in list(self._sessions.items()):
|
|
53
|
+
if session.id == session_id:
|
|
54
|
+
del self._sessions[token]
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
async def delete_user_sessions(self, user_id: str) -> None:
|
|
58
|
+
for token, session in list(self._sessions.items()):
|
|
59
|
+
if session.user_id == user_id:
|
|
60
|
+
del self._sessions[token]
|
|
61
|
+
|
|
62
|
+
async def update_last_activity(self, token_hash: str) -> None:
|
|
63
|
+
session = self._sessions.get(token_hash)
|
|
64
|
+
if session:
|
|
65
|
+
session.last_activity = now_ms()
|
|
66
|
+
|
|
67
|
+
async def cleanup(self, max_age_ms: int) -> None:
|
|
68
|
+
cutoff = now_ms() - max_age_ms
|
|
69
|
+
for token, session in list(self._sessions.items()):
|
|
70
|
+
if session.last_activity < cutoff:
|
|
71
|
+
del self._sessions[token]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""In-memory implementation of IUserStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from ..models import User, now_ms
|
|
8
|
+
from .interfaces import IUserStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryUserStore(IUserStore):
|
|
12
|
+
"""Keeps users in a dict. Returns copies so callers never alias the
|
|
13
|
+
stored records (changes must go through :meth:`update_user`)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._users: dict[str, User] = {}
|
|
17
|
+
|
|
18
|
+
async def get_users(self) -> list[User]:
|
|
19
|
+
return [copy.deepcopy(u) for u in self._users.values()]
|
|
20
|
+
|
|
21
|
+
def _find(self, id_or_name: str) -> User | None:
|
|
22
|
+
if id_or_name in self._users:
|
|
23
|
+
return self._users[id_or_name]
|
|
24
|
+
lower = id_or_name.lower()
|
|
25
|
+
for user in self._users.values():
|
|
26
|
+
if user.name.lower() == lower:
|
|
27
|
+
return user
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
async def get_user(self, id_or_name: str) -> User | None:
|
|
31
|
+
user = self._find(id_or_name)
|
|
32
|
+
return copy.deepcopy(user) if user else None
|
|
33
|
+
|
|
34
|
+
async def create_user(
|
|
35
|
+
self,
|
|
36
|
+
name: str,
|
|
37
|
+
email: str,
|
|
38
|
+
groups: list[str] | None = None,
|
|
39
|
+
data: dict | None = None,
|
|
40
|
+
*,
|
|
41
|
+
disabled: bool = False,
|
|
42
|
+
home_dir: str | None = None,
|
|
43
|
+
must_change_password: bool = False,
|
|
44
|
+
) -> User:
|
|
45
|
+
lower = name.lower()
|
|
46
|
+
for user in self._users.values():
|
|
47
|
+
if user.name.lower() == lower:
|
|
48
|
+
raise ValueError(f"user already exists: {name}")
|
|
49
|
+
|
|
50
|
+
now = now_ms()
|
|
51
|
+
user = User(
|
|
52
|
+
name=name,
|
|
53
|
+
email=email,
|
|
54
|
+
groups=list(groups or []),
|
|
55
|
+
disabled=disabled,
|
|
56
|
+
must_change_password=must_change_password,
|
|
57
|
+
home_dir=home_dir,
|
|
58
|
+
data=copy.deepcopy(data),
|
|
59
|
+
created_at=now,
|
|
60
|
+
updated_at=now,
|
|
61
|
+
)
|
|
62
|
+
self._users[user.id] = user
|
|
63
|
+
return copy.deepcopy(user)
|
|
64
|
+
|
|
65
|
+
async def update_user(
|
|
66
|
+
self,
|
|
67
|
+
user_id: str,
|
|
68
|
+
*,
|
|
69
|
+
name: str | None = None,
|
|
70
|
+
email: str | None = None,
|
|
71
|
+
groups: list[str] | None = None,
|
|
72
|
+
disabled: bool | None = None,
|
|
73
|
+
data: dict | None = None,
|
|
74
|
+
home_dir: str | None = None,
|
|
75
|
+
last_login_at: int | None = None,
|
|
76
|
+
must_change_password: bool | None = None,
|
|
77
|
+
) -> User:
|
|
78
|
+
user = self._users.get(user_id)
|
|
79
|
+
if not user:
|
|
80
|
+
raise KeyError(f"user not found: {user_id}")
|
|
81
|
+
|
|
82
|
+
if name is not None:
|
|
83
|
+
user.name = name
|
|
84
|
+
if email is not None:
|
|
85
|
+
user.email = email
|
|
86
|
+
if groups is not None:
|
|
87
|
+
user.groups = list(groups)
|
|
88
|
+
if disabled is not None:
|
|
89
|
+
user.disabled = disabled
|
|
90
|
+
if data is not None:
|
|
91
|
+
user.data = copy.deepcopy(data)
|
|
92
|
+
if home_dir is not None:
|
|
93
|
+
user.home_dir = home_dir
|
|
94
|
+
if last_login_at is not None:
|
|
95
|
+
user.last_login_at = last_login_at
|
|
96
|
+
if must_change_password is not None:
|
|
97
|
+
user.must_change_password = must_change_password
|
|
98
|
+
user.updated_at = now_ms()
|
|
99
|
+
return copy.deepcopy(user)
|
|
100
|
+
|
|
101
|
+
async def delete_user(self, user_id: str) -> None:
|
|
102
|
+
self._users.pop(user_id, None)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Abstract store interfaces for the users plugin.
|
|
2
|
+
|
|
3
|
+
Implement these to persist users, sessions, credentials, groups and the audit
|
|
4
|
+
log in your own storage. All business rules (permissions, cascades,
|
|
5
|
+
protections) live in the plugin; stores are plain persistence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from ..models import (
|
|
12
|
+
AuditEntry,
|
|
13
|
+
AuditQueryOptions,
|
|
14
|
+
Credentials,
|
|
15
|
+
Group,
|
|
16
|
+
Session,
|
|
17
|
+
SessionMetadata,
|
|
18
|
+
User,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IUserStore(ABC):
|
|
23
|
+
"""Persistence interface for user records."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def get_users(self) -> list[User]:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def get_user(self, id_or_name: str) -> User | None:
|
|
31
|
+
"""Look a user up by id, or by name (case-insensitive)."""
|
|
32
|
+
|
|
33
|
+
async def get_user_by_email(self, email: str) -> User | None:
|
|
34
|
+
"""Look a user up by email (case-insensitive).
|
|
35
|
+
|
|
36
|
+
Default implementation scans :meth:`get_users`; override for
|
|
37
|
+
efficiency.
|
|
38
|
+
"""
|
|
39
|
+
lower = email.strip().lower()
|
|
40
|
+
for user in await self.get_users():
|
|
41
|
+
if user.email.lower() == lower:
|
|
42
|
+
return user
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def create_user(
|
|
47
|
+
self,
|
|
48
|
+
name: str,
|
|
49
|
+
email: str,
|
|
50
|
+
groups: list[str] | None = None,
|
|
51
|
+
data: dict | None = None,
|
|
52
|
+
*,
|
|
53
|
+
disabled: bool = False,
|
|
54
|
+
home_dir: str | None = None,
|
|
55
|
+
must_change_password: bool = False,
|
|
56
|
+
) -> User:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def update_user(
|
|
61
|
+
self,
|
|
62
|
+
user_id: str,
|
|
63
|
+
*,
|
|
64
|
+
name: str | None = None,
|
|
65
|
+
email: str | None = None,
|
|
66
|
+
groups: list[str] | None = None,
|
|
67
|
+
disabled: bool | None = None,
|
|
68
|
+
data: dict | None = None,
|
|
69
|
+
home_dir: str | None = None,
|
|
70
|
+
last_login_at: int | None = None,
|
|
71
|
+
must_change_password: bool | None = None,
|
|
72
|
+
) -> User:
|
|
73
|
+
"""Update the given fields (``None`` = unchanged). Raises
|
|
74
|
+
``KeyError`` when the user does not exist."""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def delete_user(self, user_id: str) -> None:
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ISessionStore(ABC):
|
|
82
|
+
"""Persistence interface for sessions.
|
|
83
|
+
|
|
84
|
+
Sessions are keyed by ``token_hash`` — the SHA-256 (hex) of the bearer
|
|
85
|
+
token; raw tokens never reach the store. Also addressable by id.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def create_session(
|
|
90
|
+
self,
|
|
91
|
+
user_id: str,
|
|
92
|
+
token_hash: str,
|
|
93
|
+
metadata: SessionMetadata,
|
|
94
|
+
*,
|
|
95
|
+
user_name: str = "",
|
|
96
|
+
) -> Session:
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
async def get_session(self, token_hash: str) -> Session | None:
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
async def get_session_by_id(self, session_id: str) -> Session | None:
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
async def get_active_sessions(self) -> list[Session]:
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
async def delete_session(self, token_hash: str) -> None:
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
async def delete_session_by_id(self, session_id: str) -> None:
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
async def delete_user_sessions(self, user_id: str) -> None:
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
async def update_last_activity(self, token_hash: str) -> None:
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
async def cleanup(self, max_age_ms: int) -> None:
|
|
129
|
+
"""Delete sessions idle for longer than ``max_age_ms``."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ICredentialStore(ABC):
|
|
133
|
+
"""Persistence interface for user credentials."""
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
async def set_password(self, user_id: str, hash_: str, salt: str) -> None:
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
async def get_credentials(self, user_id: str) -> Credentials | None:
|
|
141
|
+
...
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
async def delete_credentials(self, user_id: str) -> None:
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class IAuditStore(ABC):
|
|
149
|
+
"""Persistence interface for audit log entries."""
|
|
150
|
+
|
|
151
|
+
@abstractmethod
|
|
152
|
+
async def log(self, entry: AuditEntry) -> None:
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
async def get_entries(self, options: AuditQueryOptions | None = None) -> list[AuditEntry]:
|
|
157
|
+
"""Entries newest-first, filtered by ``options``."""
|
|
158
|
+
|
|
159
|
+
@abstractmethod
|
|
160
|
+
async def clear(self) -> None:
|
|
161
|
+
...
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class IGroupStore(ABC):
|
|
165
|
+
"""Persistence interface for user groups. Lookups are case-insensitive."""
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
async def get_groups(self) -> list[Group]:
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
@abstractmethod
|
|
172
|
+
async def get_group(self, name: str) -> Group | None:
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
@abstractmethod
|
|
176
|
+
async def create_group(
|
|
177
|
+
self,
|
|
178
|
+
name: str,
|
|
179
|
+
description: str | None = None,
|
|
180
|
+
permissions: list[str] | None = None,
|
|
181
|
+
*,
|
|
182
|
+
built_in: bool = False,
|
|
183
|
+
) -> Group:
|
|
184
|
+
"""Raises ``ValueError`` when a group with that name exists."""
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
async def update_group(
|
|
188
|
+
self,
|
|
189
|
+
name: str,
|
|
190
|
+
*,
|
|
191
|
+
description: str | None = None,
|
|
192
|
+
permissions: list[str] | None = None,
|
|
193
|
+
) -> Group:
|
|
194
|
+
"""Update the given fields (``None`` = unchanged). Raises
|
|
195
|
+
``KeyError`` when the group does not exist."""
|
|
196
|
+
|
|
197
|
+
@abstractmethod
|
|
198
|
+
async def delete_group(self, name: str) -> None:
|
|
199
|
+
...
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = [
|
|
203
|
+
"IAuditStore",
|
|
204
|
+
"ICredentialStore",
|
|
205
|
+
"IGroupStore",
|
|
206
|
+
"ISessionStore",
|
|
207
|
+
"IUserStore",
|
|
208
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Input validation rules (security contract B7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import bad_request
|
|
10
|
+
|
|
11
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]*$")
|
|
12
|
+
NAME_MAX = 64
|
|
13
|
+
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+$")
|
|
14
|
+
EMAIL_MAX = 254
|
|
15
|
+
PERMISSION_RE = re.compile(r"^(\*|[a-z0-9_.-]+(:([a-z0-9_.-]+|\*))*)$", re.IGNORECASE)
|
|
16
|
+
PERMISSION_MAX = 128
|
|
17
|
+
PERMISSIONS_PER_GROUP_MAX = 100
|
|
18
|
+
DESCRIPTION_MAX = 256
|
|
19
|
+
DATA_MAX_BYTES = 16 * 1024
|
|
20
|
+
BODY_MAX_BYTES = 1024 * 1024
|
|
21
|
+
PASSWORD_MAX = 1024
|
|
22
|
+
|
|
23
|
+
# Control characters, plus lone UTF-16 surrogates (valid in JSON escapes but
|
|
24
|
+
# not encodable as UTF-8: stored, they make every response containing them fail).
|
|
25
|
+
_CONTROL_RE = re.compile(r"[\u0000-\u001f\u007f\ud800-\udfff]")
|
|
26
|
+
_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_control_chars(value: str) -> bool:
|
|
30
|
+
return bool(_CONTROL_RE.search(value))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def has_lone_surrogates(value: str) -> bool:
|
|
34
|
+
return bool(_SURROGATE_RE.search(value))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sanitize(value: str | None, max_len: int | None = None) -> str | None:
|
|
38
|
+
"""Strip control characters (incl. ANSI ESC) and truncate."""
|
|
39
|
+
if value is None:
|
|
40
|
+
return None
|
|
41
|
+
cleaned = _CONTROL_RE.sub("", value)
|
|
42
|
+
return cleaned[:max_len] if max_len is not None else cleaned
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_name(value: str, kind: str = "name") -> str:
|
|
46
|
+
if not value:
|
|
47
|
+
raise bad_request(f"{kind} is required")
|
|
48
|
+
if len(value) > NAME_MAX:
|
|
49
|
+
raise bad_request(f"{kind} must be at most {NAME_MAX} characters")
|
|
50
|
+
if not NAME_RE.match(value):
|
|
51
|
+
raise bad_request(
|
|
52
|
+
f"invalid {kind}: must start with a letter or digit and contain only "
|
|
53
|
+
"letters, digits, '.', '_', '@' or '-'"
|
|
54
|
+
)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_email(value: str) -> str:
|
|
59
|
+
if not value:
|
|
60
|
+
raise bad_request("email is required")
|
|
61
|
+
if len(value) > EMAIL_MAX or has_control_chars(value) or not EMAIL_RE.match(value):
|
|
62
|
+
raise bad_request("invalid email")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_text(value: str | None, field: str, max_len: int) -> str | None:
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
if has_control_chars(value):
|
|
70
|
+
raise bad_request(f"{field} must not contain control characters")
|
|
71
|
+
if len(value) > max_len:
|
|
72
|
+
raise bad_request(f"{field} must be at most {max_len} characters")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_permissions(permissions: list[str] | None) -> list[str] | None:
|
|
77
|
+
if permissions is None:
|
|
78
|
+
return None
|
|
79
|
+
if len(permissions) > PERMISSIONS_PER_GROUP_MAX:
|
|
80
|
+
raise bad_request(f"at most {PERMISSIONS_PER_GROUP_MAX} permissions per group")
|
|
81
|
+
out: list[str] = []
|
|
82
|
+
for raw in permissions:
|
|
83
|
+
p = raw.strip()
|
|
84
|
+
if not p or len(p) > PERMISSION_MAX or not PERMISSION_RE.match(p):
|
|
85
|
+
raise bad_request(f"invalid permission: {sanitize(raw, PERMISSION_MAX)}")
|
|
86
|
+
if p.lower() not in (x.lower() for x in out):
|
|
87
|
+
out.append(p)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_data(data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
92
|
+
if data is None:
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
size = len(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
|
|
96
|
+
except (TypeError, ValueError) as exc:
|
|
97
|
+
raise bad_request("invalid data") from exc
|
|
98
|
+
if size > DATA_MAX_BYTES:
|
|
99
|
+
raise bad_request(f"data must be at most {DATA_MAX_BYTES} bytes")
|
|
100
|
+
return data
|