qodalis-cli-server-plugin-users 1.0.0b6__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.
Files changed (32) hide show
  1. qodalis_cli_server_plugin_users-1.0.0b6/.gitignore +44 -0
  2. qodalis_cli_server_plugin_users-1.0.0b6/LICENSE +21 -0
  3. qodalis_cli_server_plugin_users-1.0.0b6/PKG-INFO +21 -0
  4. qodalis_cli_server_plugin_users-1.0.0b6/pyproject.toml +39 -0
  5. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/__init__.py +134 -0
  6. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/_http.py +162 -0
  7. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/cli_users_builder.py +385 -0
  8. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/controllers/__init__.py +13 -0
  9. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/controllers/_routing.py +25 -0
  10. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/controllers/audit_controller.py +40 -0
  11. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/controllers/groups_controller.py +109 -0
  12. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/controllers/sessions_controller.py +114 -0
  13. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/controllers/users_controller.py +183 -0
  14. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/errors.py +118 -0
  15. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/middleware/__init__.py +5 -0
  16. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/middleware/auth_middleware.py +77 -0
  17. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/models.py +282 -0
  18. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/permissions.py +58 -0
  19. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/server_protection.py +209 -0
  20. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/services/__init__.py +6 -0
  21. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/services/auth_service.py +786 -0
  22. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/services/password_hasher.py +98 -0
  23. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/services/users_service.py +330 -0
  24. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/shell_access.py +125 -0
  25. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/__init__.py +27 -0
  26. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
  27. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
  28. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/in_memory_group_store.py +65 -0
  29. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/in_memory_session_store.py +71 -0
  30. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/in_memory_user_store.py +102 -0
  31. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/stores/interfaces.py +208 -0
  32. qodalis_cli_server_plugin_users-1.0.0b6/qodalis_cli_users/validation.py +100 -0
@@ -0,0 +1,44 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ *.egg
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Environment variables
19
+ .env
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ TestResults/
26
+
27
+ # Type checking / linting
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+
31
+ # C extensions
32
+ *.so
33
+
34
+ # IDEs and editors
35
+ .idea/
36
+ .vscode/*
37
+ !.vscode/settings.json
38
+ !.vscode/launch.json
39
+ !.vscode/extensions.json
40
+
41
+ # System files
42
+ .DS_Store
43
+ Thumbs.db
44
+ node_modules/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Qodalis Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.5
2
+ Name: qodalis-cli-server-plugin-users
3
+ Version: 1.0.0b6
4
+ Summary: User management plugin for Qodalis CLI servers — authentication, sessions, groups, and audit log
5
+ Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
6
+ Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
7
+ Project-URL: Issues, https://github.com/qodalis-solutions/cli-server-python/issues
8
+ Author-email: Nicolae Lupei <nicolae.lupei@qodalis.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: auth,cli,plugin,qodalis,sessions,users
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: fastapi>=0.100.0
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qodalis-cli-server-plugin-users"
7
+ version = "1.0.0b6"
8
+ description = "User management plugin for Qodalis CLI servers — authentication, sessions, groups, and audit log"
9
+ readme = { text = "", content-type = "text/plain" }
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Nicolae Lupei", email = "nicolae.lupei@qodalis.com" },
14
+ ]
15
+ keywords = ["cli", "qodalis", "users", "auth", "sessions", "plugin"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ dependencies = [
27
+ "fastapi>=0.100.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/qodalis-solutions/cli-server-python"
32
+ Repository = "https://github.com/qodalis-solutions/cli-server-python"
33
+ Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["qodalis_cli_users"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["qodalis_cli_users/**"]
@@ -0,0 +1,134 @@
1
+ """Qodalis CLI users plugin — user management, authentication, permissions,
2
+ sessions, groups and audit."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import warnings
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable, Optional
9
+
10
+ from .cli_users_builder import CliUsersBuilder, CliUsersPlugin
11
+ from .errors import UsersApiError
12
+ from .models import (
13
+ AuditEntry,
14
+ AuditQueryOptions,
15
+ Credentials,
16
+ ExternalAuthAdapter,
17
+ ExternalAuthResult,
18
+ Group,
19
+ Session,
20
+ SessionMetadata,
21
+ User,
22
+ )
23
+ from .permissions import has_permission, merge_permissions, permission_matches
24
+ from .services.auth_service import (
25
+ DEFAULT_SESSION_TIMEOUT_MS,
26
+ AuthContext,
27
+ AuthService,
28
+ AuthServiceOptions,
29
+ )
30
+ from .services.users_service import UsersService
31
+ from .server_protection import ServerProtectionMiddleware
32
+ from .shell_access import UsersShellAccess
33
+ from .stores.in_memory_audit_store import InMemoryAuditStore
34
+ from .stores.in_memory_credential_store import InMemoryCredentialStore
35
+ from .stores.in_memory_group_store import InMemoryGroupStore
36
+ from .stores.in_memory_session_store import InMemorySessionStore
37
+ from .stores.in_memory_user_store import InMemoryUserStore
38
+ from .stores.interfaces import (
39
+ IAuditStore,
40
+ ICredentialStore,
41
+ IGroupStore,
42
+ ISessionStore,
43
+ IUserStore,
44
+ )
45
+
46
+ __all__ = [
47
+ "AuditEntry",
48
+ "AuditQueryOptions",
49
+ "AuthContext",
50
+ "AuthService",
51
+ "AuthServiceOptions",
52
+ "CliUsersBuilder",
53
+ "CliUsersPlugin",
54
+ "Credentials",
55
+ "ExternalAuthAdapter",
56
+ "ExternalAuthResult",
57
+ "Group",
58
+ "IAuditStore",
59
+ "ICredentialStore",
60
+ "IGroupStore",
61
+ "ISessionStore",
62
+ "IUserStore",
63
+ "InMemoryAuditStore",
64
+ "InMemoryCredentialStore",
65
+ "InMemoryGroupStore",
66
+ "InMemorySessionStore",
67
+ "InMemoryUserStore",
68
+ "Session",
69
+ "ServerProtectionMiddleware",
70
+ "SessionMetadata",
71
+ "User",
72
+ "UsersApiError",
73
+ "UsersPluginOptions",
74
+ "UsersPluginResult",
75
+ "UsersService",
76
+ "UsersShellAccess",
77
+ "build_users_plugin",
78
+ "has_permission",
79
+ "merge_permissions",
80
+ "permission_matches",
81
+ ]
82
+
83
+ #: Deprecated alias of :class:`CliUsersPlugin`.
84
+ UsersPluginResult = CliUsersPlugin
85
+
86
+
87
+ @dataclass
88
+ class UsersPluginOptions:
89
+ """Deprecated options for :func:`build_users_plugin`; use
90
+ :class:`CliUsersBuilder`."""
91
+
92
+ user_store: IUserStore | None = None
93
+ session_store: ISessionStore | None = None
94
+ credential_store: ICredentialStore | None = None
95
+ audit_store: IAuditStore | None = None
96
+ group_store: IGroupStore | None = None
97
+ session_timeout_ms: int = DEFAULT_SESSION_TIMEOUT_MS
98
+ default_admin_password: str | None = None
99
+ auth_adapter: Optional[ExternalAuthAdapter] = None
100
+
101
+
102
+ async def build_users_plugin(
103
+ options: UsersPluginOptions | None = None,
104
+ broadcast_event: Callable[[str], Any] | None = None,
105
+ ) -> CliUsersPlugin:
106
+ """Deprecated: use :class:`CliUsersBuilder` and call ``startup()`` from
107
+ the app lifespan. Builds the plugin and runs ``startup()`` on the
108
+ current loop."""
109
+ warnings.warn(
110
+ "build_users_plugin() is deprecated; use CliUsersBuilder().build() and "
111
+ "await plugin.startup() in the app lifespan",
112
+ DeprecationWarning,
113
+ stacklevel=2,
114
+ )
115
+ opts = options or UsersPluginOptions()
116
+ builder = (
117
+ CliUsersBuilder()
118
+ .set_default_admin_password(opts.default_admin_password)
119
+ .set_session_timeout(opts.session_timeout_ms)
120
+ .set_auth_adapter(opts.auth_adapter)
121
+ )
122
+ if opts.user_store:
123
+ builder.set_user_store(opts.user_store)
124
+ if opts.session_store:
125
+ builder.set_session_store(opts.session_store)
126
+ if opts.credential_store:
127
+ builder.set_credential_store(opts.credential_store)
128
+ if opts.audit_store:
129
+ builder.set_audit_store(opts.audit_store)
130
+ if opts.group_store:
131
+ builder.set_group_store(opts.group_store)
132
+ plugin = builder.build(broadcast_event)
133
+ await plugin.startup()
134
+ return plugin
@@ -0,0 +1,162 @@
1
+ """Small HTTP/ASGI helpers (kept local so the plugin does not depend on the
2
+ core server package)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ from typing import Any, Iterable
8
+ from urllib.parse import urlsplit
9
+
10
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
11
+ #: Key under ``scope["state"]`` where the authenticated caller is stored
12
+ #: (shared with ``qodalis_cli.security``).
13
+ AUTH_STATE_KEY = "qcli_auth"
14
+
15
+
16
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
17
+ _WS_SCHEMES = {"ws": "http", "wss": "https"}
18
+
19
+
20
+ def _scope_header(scope: Any, name: str) -> str | None:
21
+ raw = name.encode("latin-1")
22
+ for key, value in (scope.get("headers") if isinstance(scope, dict) else None) or []:
23
+ if key.lower() == raw:
24
+ return value.decode("latin-1")
25
+ return None
26
+
27
+
28
+ def trusted_hops(trust_proxy: bool | int | None) -> int:
29
+ """Number of trusted reverse-proxy hops for a ``trust_proxy`` setting.
30
+
31
+ ``False``/``0``/``None`` trust no proxy, ``True`` one hop, an int ``n``
32
+ ``n`` hops; any other enabled value below 1 (a negative int) counts as
33
+ one hop. ``bool`` is checked first (``True == 1`` in Python).
34
+ """
35
+ if isinstance(trust_proxy, bool):
36
+ return 1 if trust_proxy else 0
37
+ if not trust_proxy:
38
+ return 0
39
+ return max(int(trust_proxy), 1)
40
+
41
+
42
+ def forwarded_client_ip(headers: Any, trust_proxy: bool | int | None) -> str | None:
43
+ """Client IP from ``X-Forwarded-For`` behind ``trust_proxy`` trusted hops.
44
+
45
+ Every proxy appends the address it saw, so only the rightmost ``n``
46
+ entries were written by trusted proxies; the ones to their left are
47
+ client-controlled. The client IP is the entry at ``len - n`` of the
48
+ comma-separated, trimmed list (the rightmost entry for one hop), or the
49
+ leftmost entry when the list has fewer than ``n`` entries. Returns
50
+ ``None`` (use the socket peer) when no proxy is trusted, the header is
51
+ absent/empty, or the selected entry is empty.
52
+ """
53
+ hops = trusted_hops(trust_proxy)
54
+ if hops == 0:
55
+ return None
56
+ entries = [
57
+ entry.strip()
58
+ for value in headers.getlist("x-forwarded-for")
59
+ for entry in value.split(",")
60
+ ]
61
+ if not entries:
62
+ return None
63
+ return entries[max(len(entries) - hops, 0)] or None
64
+
65
+
66
+ def _normalize_origin(scheme: str, netloc: str) -> tuple[str, str, int] | None:
67
+ scheme = _WS_SCHEMES.get(scheme.strip().lower(), scheme.strip().lower())
68
+ if scheme not in _DEFAULT_PORTS or not netloc:
69
+ return None
70
+ try:
71
+ parts = urlsplit(f"//{netloc.strip()}")
72
+ host = (parts.hostname or "").lower()
73
+ port = parts.port
74
+ except ValueError:
75
+ return None
76
+ if not host:
77
+ return None
78
+ return scheme, host, port if port is not None else _DEFAULT_PORTS[scheme]
79
+
80
+
81
+ def is_same_origin(origin: str | None, scope: Any, trust_proxy: bool | int = False) -> bool:
82
+ """True when ``origin`` equals the request's own origin (scheme + Host).
83
+
84
+ ``ws``/``wss`` map to ``http``/``https``; default ports are normalized and
85
+ the comparison is case-insensitive. ``X-Forwarded-Proto`` /
86
+ ``X-Forwarded-Host`` are honoured only when ``trust_proxy`` is on. A
87
+ same-origin request is never cross-site, so it always passes Origin checks.
88
+ """
89
+ if not origin or not isinstance(scope, dict):
90
+ return False
91
+ try:
92
+ o = urlsplit(origin.strip().rstrip("/"))
93
+ except ValueError:
94
+ return False
95
+ if o.path or o.query or o.fragment:
96
+ return False
97
+ actual = _normalize_origin(o.scheme, o.netloc)
98
+ if actual is None:
99
+ return False
100
+ scheme = scope.get("scheme") or "http"
101
+ host = _scope_header(scope, "host")
102
+ if trust_proxy:
103
+ fwd_proto = _scope_header(scope, "x-forwarded-proto")
104
+ fwd_host = _scope_header(scope, "x-forwarded-host")
105
+ if fwd_proto and fwd_proto.split(",")[0].strip():
106
+ scheme = fwd_proto.split(",")[0].strip()
107
+ if fwd_host and fwd_host.split(",")[0].strip():
108
+ host = fwd_host.split(",")[0].strip()
109
+ if not host:
110
+ return False
111
+ return actual == _normalize_origin(scheme, host)
112
+
113
+
114
+ def origin_allowed(
115
+ origin: str | None,
116
+ allowed: Iterable[str] | None,
117
+ scope: Any = None,
118
+ trust_proxy: bool | int = False,
119
+ ) -> bool:
120
+ """True when ``origin`` matches ``allowed`` (``*`` globs) or, when the
121
+ request ``scope`` is given, equals the request's own origin."""
122
+ if not origin:
123
+ return False
124
+ if scope is not None and is_same_origin(origin, scope, trust_proxy):
125
+ return True
126
+ origin = origin.rstrip("/").lower()
127
+ for pattern in allowed or []:
128
+ pattern = pattern.rstrip("/").lower()
129
+ if pattern == "*" or pattern == origin or fnmatch.fnmatchcase(origin, pattern):
130
+ return True
131
+ return False
132
+
133
+
134
+ def has_bearer(headers: Any) -> bool:
135
+ auth = headers.get("authorization", "") or ""
136
+ return auth[:7].lower() == "bearer " and bool(auth[7:].strip())
137
+
138
+
139
+ def bearer_token(headers: Any) -> str | None:
140
+ auth = headers.get("authorization", "") or ""
141
+ if auth[:7].lower() != "bearer ":
142
+ return None
143
+ token = auth[7:].strip()
144
+ return token or None
145
+
146
+
147
+ def get_scope_auth(scope: dict) -> Any:
148
+ state = scope.get("state")
149
+ if isinstance(state, dict):
150
+ return state.get(AUTH_STATE_KEY)
151
+ return getattr(state, AUTH_STATE_KEY, None) if state is not None else None
152
+
153
+
154
+ def set_scope_auth(scope: dict, ctx: Any) -> None:
155
+ state = scope.get("state")
156
+ if state is None:
157
+ state = {}
158
+ scope["state"] = state
159
+ if isinstance(state, dict):
160
+ state[AUTH_STATE_KEY] = ctx
161
+ else:
162
+ setattr(state, AUTH_STATE_KEY, ctx)