keepup-admin 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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""What every authentication provider has to be able to do.
|
|
2
|
+
|
|
3
|
+
The interface exists so that where accounts live stays a deployment decision:
|
|
4
|
+
the rest of the framework asks a provider to authenticate and to describe a
|
|
5
|
+
user, and never learns whether that meant a table, a directory or something
|
|
6
|
+
else. Token issuing is shared here rather than reimplemented per provider --
|
|
7
|
+
two implementations of signing are two places to get the signature wrong.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Optional, Dict, Any, List
|
|
13
|
+
|
|
14
|
+
import bcrypt
|
|
15
|
+
|
|
16
|
+
from fastapi import HTTPException
|
|
17
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
18
|
+
from jose import jwt
|
|
19
|
+
from starlette import status
|
|
20
|
+
|
|
21
|
+
from keepup.auth.signing_key import resolve_signing_key
|
|
22
|
+
from keepup.roles import ROLE_CLIENT
|
|
23
|
+
from keepup.auth.config import auth_config
|
|
24
|
+
from keepup.db import DatabaseManager, db_config
|
|
25
|
+
|
|
26
|
+
#: What an application may import from this module. Everything else is
|
|
27
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ACCESS_TOKEN_EXPIRE_MINUTES",
|
|
30
|
+
"ALGORITHM",
|
|
31
|
+
"AuthProvider",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
#: The signing key lives in the environment -- see app/auth/signing_key.py.
|
|
37
|
+
#: It is resolved per call rather than bound here, so a deployment that
|
|
38
|
+
#: changes it does not depend on which module was imported first.
|
|
39
|
+
ALGORITHM = "HS256"
|
|
40
|
+
|
|
41
|
+
#: How long an issued token lives, in minutes. Read from the deployment's
|
|
42
|
+
#: authentication configuration rather than fixed here: `jwt.expire_minutes`
|
|
43
|
+
#: was parsed out of config/auth.yaml and then read by nobody, so a deployment
|
|
44
|
+
#: that asked for fifteen minutes went on handing out day-long tokens and had
|
|
45
|
+
#: no way to tell (task keepup-14).
|
|
46
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = getattr(auth_config, "jwt_expire_minutes", 1440) or 1440
|
|
47
|
+
|
|
48
|
+
class AuthProvider(ABC):
|
|
49
|
+
"""Abstract base class for authentication providers."""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self.type = "base"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
57
|
+
"""Authenticate a user."""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def get_user_info(self, username: str) -> Optional[Dict[str, Any]]:
|
|
62
|
+
"""Return information about a user."""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def get_user_permissions(self, username: str) -> Dict[str, bool]:
|
|
67
|
+
"""Return a user's permissions."""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def create_user(self, user_data: Dict[str, Any]) -> bool:
|
|
72
|
+
"""Create a user, where the provider supports it."""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def _determine_role_from_permissions(self, permissions: Dict[str, bool]) -> str:
|
|
76
|
+
"""Derive the user's role from their permissions (base implementation)."""
|
|
77
|
+
if permissions.get("admin", False):
|
|
78
|
+
return "ADMIN"
|
|
79
|
+
elif permissions.get("manage_users", False):
|
|
80
|
+
return "MANAGER"
|
|
81
|
+
elif permissions.get("manage_products", False):
|
|
82
|
+
return "MANAGER"
|
|
83
|
+
elif permissions.get("view_reports", False):
|
|
84
|
+
return "SUPPORT"
|
|
85
|
+
else:
|
|
86
|
+
return auth_config.default_role
|
|
87
|
+
|
|
88
|
+
async def get_user_info_from_token(self, token: str) -> Optional[Dict[str, Any]]:
|
|
89
|
+
"""Return user information carried by a JWT token (base implementation)."""
|
|
90
|
+
try:
|
|
91
|
+
payload = jwt.decode(
|
|
92
|
+
token,
|
|
93
|
+
resolve_signing_key(),
|
|
94
|
+
algorithms=[auth_config.jwt_algorithm if hasattr(auth_config, 'jwt_algorithm') else ALGORITHM]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
username = payload.get("sub")
|
|
98
|
+
auth_source = payload.get("auth_source", "local")
|
|
99
|
+
|
|
100
|
+
if username:
|
|
101
|
+
if auth_source == self.get_auth_source():
|
|
102
|
+
return await self.get_user_info(username)
|
|
103
|
+
|
|
104
|
+
logger.warning(
|
|
105
|
+
f"Token auth_source ({auth_source}) doesn't match provider ({self.get_auth_source()})")
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
except jwt.ExpiredSignatureError:
|
|
110
|
+
logger.warning("JWT token expired")
|
|
111
|
+
return None
|
|
112
|
+
except jwt.JWTError as e:
|
|
113
|
+
logger.error(f"JWT error: {str(e)}")
|
|
114
|
+
return None
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error(f"Error getting user info from token: {str(e)}")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def get_auth_source(self) -> str:
|
|
120
|
+
"""Return the identifier of this authentication source."""
|
|
121
|
+
# Subclasses are expected to override this.
|
|
122
|
+
return "unknown"
|
|
123
|
+
|
|
124
|
+
def create_user_in_db(
|
|
125
|
+
self,
|
|
126
|
+
username: str,
|
|
127
|
+
password: str,
|
|
128
|
+
email: str = None,
|
|
129
|
+
phone: str = None,
|
|
130
|
+
full_name: str = None,
|
|
131
|
+
agree_terms: bool = False
|
|
132
|
+
):
|
|
133
|
+
"""Create a user, including the extra fields and the terms agreement."""
|
|
134
|
+
|
|
135
|
+
if not agree_terms:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
138
|
+
detail="You must agree to the terms of use"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
142
|
+
|
|
143
|
+
query = """
|
|
144
|
+
INSERT INTO users (username, password_hash, email, phone, full_name, agree_terms, status, role, auth_source)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if db_config.is_postgres():
|
|
149
|
+
query += " RETURNING id"
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
user_id = DatabaseManager.execute_commit(
|
|
153
|
+
query,
|
|
154
|
+
(username, password_hash, email, phone, full_name, agree_terms, "blocked", ROLE_CLIENT, self.type)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
logger.info(f"New user registered: {username} (agree_terms: {agree_terms})")
|
|
158
|
+
|
|
159
|
+
return user_id
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
if "unique constraint" in str(e).lower() or "duplicate" in str(e).lower():
|
|
163
|
+
raise HTTPException(status_code=400, detail="Username already exists")
|
|
164
|
+
logger.error(f"Error creating user: {str(e)}")
|
|
165
|
+
raise e
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login", auto_error=False)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Accounts kept in this application's own `users` table.
|
|
2
|
+
|
|
3
|
+
The only provider since task 170. It answers three questions -- is this the
|
|
4
|
+
password, who is this user, what may they do -- against the database the
|
|
5
|
+
application already has, which is what makes a deployment need no directory
|
|
6
|
+
service to stand up.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
import bcrypt
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
# The same library the rest of the package signs and reads tokens with. This
|
|
15
|
+
# was a bare `import jwt`, which resolves to whichever of two installed
|
|
16
|
+
# distributions won the directory -- and the one pinned in requirements.txt is
|
|
17
|
+
# not the one with a module-level decode(). The call fell into `except
|
|
18
|
+
# Exception` and returned None, so the path was closed by accident rather than
|
|
19
|
+
# by decision (task keepup-15).
|
|
20
|
+
from jose import jwt
|
|
21
|
+
from starlette import status
|
|
22
|
+
|
|
23
|
+
from keepup.roles import ROLE_CLIENT
|
|
24
|
+
from keepup.auth.config import auth_config
|
|
25
|
+
from keepup.db import DatabaseManager, db_config
|
|
26
|
+
from keepup.auth.signing_key import resolve_signing_key
|
|
27
|
+
from .base import AuthProvider, ALGORITHM
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger()
|
|
30
|
+
|
|
31
|
+
def get_user_by_username(username: str):
|
|
32
|
+
"""Return a user by name, including the extra fields."""
|
|
33
|
+
return DatabaseManager.execute_sql_one('''
|
|
34
|
+
SELECT id, username, email, phone, full_name, agree_terms, password_hash, status, role, created_at, updated_at
|
|
35
|
+
FROM users WHERE username = ?
|
|
36
|
+
''', (username,))
|
|
37
|
+
|
|
38
|
+
def get_user_by_id(user_id: int):
|
|
39
|
+
"""Return a user by id, for any configured auth provider."""
|
|
40
|
+
result = DatabaseManager.execute_query(
|
|
41
|
+
"SELECT * FROM users WHERE id = ?",
|
|
42
|
+
(user_id,),
|
|
43
|
+
fetch_one=True
|
|
44
|
+
)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
def verify_password(plain_password: str, hashed_password: str):
|
|
48
|
+
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
|
49
|
+
|
|
50
|
+
class LocalAuthProvider(AuthProvider):
|
|
51
|
+
"""Local authentication provider backed by the existing database."""
|
|
52
|
+
|
|
53
|
+
def _determine_role_from_permissions(self, permissions: Dict[str, bool]) -> str:
|
|
54
|
+
"""Derive the user's role from their permissions."""
|
|
55
|
+
# Local users get their role at creation time, so the stored role wins over
|
|
56
|
+
# the permission set. This override exists for the cases where permissions
|
|
57
|
+
# should decide instead.
|
|
58
|
+
if permissions.get("admin", False):
|
|
59
|
+
return "ADMIN"
|
|
60
|
+
elif permissions.get("manager", False):
|
|
61
|
+
return "MANAGER"
|
|
62
|
+
elif permissions.get("support", False):
|
|
63
|
+
return "SUPPORT"
|
|
64
|
+
else:
|
|
65
|
+
return auth_config.default_role
|
|
66
|
+
|
|
67
|
+
async def get_user_info_from_token(self, token: str) -> Optional[Dict[str, Any]]:
|
|
68
|
+
"""Return user information carried by a JWT token."""
|
|
69
|
+
try:
|
|
70
|
+
payload = jwt.decode(
|
|
71
|
+
token,
|
|
72
|
+
resolve_signing_key(),
|
|
73
|
+
algorithms=[auth_config.jwt_algorithm if hasattr(auth_config, 'jwt_algorithm') else ALGORITHM]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
username = payload.get("sub")
|
|
77
|
+
if username:
|
|
78
|
+
return await self.get_user_info(username)
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Error getting user info from token: {str(e)}")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
async def create_user(self, user_data: Dict[str, Any]) -> bool:
|
|
87
|
+
"""Create a local user."""
|
|
88
|
+
try:
|
|
89
|
+
from keepup.auth.dependencies import create_user as create_user_func
|
|
90
|
+
|
|
91
|
+
user_id = await create_user_func(
|
|
92
|
+
username=user_data["username"],
|
|
93
|
+
password=user_data["password"],
|
|
94
|
+
email=user_data.get("email"),
|
|
95
|
+
phone=user_data.get("phone"),
|
|
96
|
+
full_name=user_data.get("full_name"),
|
|
97
|
+
agree_terms=user_data.get("agree_terms", False)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if user_id:
|
|
101
|
+
default_permissions = ["authenticated", "view_products", "edit_own_profile"]
|
|
102
|
+
#for permission in default_permissions:
|
|
103
|
+
# set_user_permission(user_id, permission, True)
|
|
104
|
+
|
|
105
|
+
logger.info(f"Local user {user_data['username']} created with ID {user_id}")
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error(f"Error creating local user: {str(e)}")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
async def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
115
|
+
user = get_user_by_username(username)
|
|
116
|
+
if not user:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
if not verify_password(password, user["password_hash"]):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
return user
|
|
123
|
+
|
|
124
|
+
async def get_user_info(self, username: str) -> Optional[Dict[str, Any]]:
|
|
125
|
+
return get_user_by_username(username)
|
|
126
|
+
|
|
127
|
+
async def get_user_permissions(self, username: str) -> Dict[str, bool]:
|
|
128
|
+
user = get_user_by_username(username)
|
|
129
|
+
if not user:
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
permissions = DatabaseManager.execute_sql('''
|
|
133
|
+
SELECT permission_name, granted
|
|
134
|
+
FROM user_permissions
|
|
135
|
+
WHERE user_id = ?
|
|
136
|
+
''', (user["id"],))
|
|
137
|
+
|
|
138
|
+
return {p["permission_name"]: p["granted"] for p in permissions}
|
|
139
|
+
|
|
140
|
+
def create_user_sync(
|
|
141
|
+
self,
|
|
142
|
+
username: str,
|
|
143
|
+
password: str,
|
|
144
|
+
email: str = None,
|
|
145
|
+
phone: str = None,
|
|
146
|
+
full_name: str = None,
|
|
147
|
+
agree_terms: bool = False
|
|
148
|
+
):
|
|
149
|
+
"""Create a user, including the extra fields and the terms agreement."""
|
|
150
|
+
|
|
151
|
+
if not agree_terms:
|
|
152
|
+
raise HTTPException(
|
|
153
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
154
|
+
detail="You must agree to the terms of use"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
158
|
+
|
|
159
|
+
query = """
|
|
160
|
+
INSERT INTO users (username, password_hash, email, phone, full_name, agree_terms, status, role)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
if db_config.is_postgres():
|
|
165
|
+
query += " RETURNING id"
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
user_id = DatabaseManager.execute_commit(
|
|
169
|
+
query,
|
|
170
|
+
(username, password_hash, email, phone, full_name, agree_terms, "blocked", ROLE_CLIENT)
|
|
171
|
+
)
|
|
172
|
+
logger.info(f"New user registered: {username} (agree_terms: {agree_terms})")
|
|
173
|
+
|
|
174
|
+
return user_id
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
if "unique constraint" in str(e).lower() or "duplicate" in str(e).lower():
|
|
178
|
+
raise HTTPException(status_code=400, detail="Username already exists")
|
|
179
|
+
logger.error(f"Error creating user: {str(e)}")
|
|
180
|
+
raise e
|