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.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. 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