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.
Files changed (32) hide show
  1. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/METADATA +21 -0
  2. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/RECORD +32 -0
  3. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/WHEEL +4 -0
  4. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/licenses/LICENSE +21 -0
  5. qodalis_cli_users/__init__.py +134 -0
  6. qodalis_cli_users/_http.py +162 -0
  7. qodalis_cli_users/cli_users_builder.py +385 -0
  8. qodalis_cli_users/controllers/__init__.py +13 -0
  9. qodalis_cli_users/controllers/_routing.py +25 -0
  10. qodalis_cli_users/controllers/audit_controller.py +40 -0
  11. qodalis_cli_users/controllers/groups_controller.py +109 -0
  12. qodalis_cli_users/controllers/sessions_controller.py +114 -0
  13. qodalis_cli_users/controllers/users_controller.py +183 -0
  14. qodalis_cli_users/errors.py +118 -0
  15. qodalis_cli_users/middleware/__init__.py +5 -0
  16. qodalis_cli_users/middleware/auth_middleware.py +77 -0
  17. qodalis_cli_users/models.py +282 -0
  18. qodalis_cli_users/permissions.py +58 -0
  19. qodalis_cli_users/server_protection.py +209 -0
  20. qodalis_cli_users/services/__init__.py +6 -0
  21. qodalis_cli_users/services/auth_service.py +786 -0
  22. qodalis_cli_users/services/password_hasher.py +98 -0
  23. qodalis_cli_users/services/users_service.py +330 -0
  24. qodalis_cli_users/shell_access.py +125 -0
  25. qodalis_cli_users/stores/__init__.py +27 -0
  26. qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
  27. qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
  28. qodalis_cli_users/stores/in_memory_group_store.py +65 -0
  29. qodalis_cli_users/stores/in_memory_session_store.py +71 -0
  30. qodalis_cli_users/stores/in_memory_user_store.py +102 -0
  31. qodalis_cli_users/stores/interfaces.py +208 -0
  32. 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