pylantir 0.1.3__py3-none-any.whl → 0.2.1__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.
- pylantir/__init__.py +1 -1
- pylantir/api_server.py +769 -0
- pylantir/auth_db_setup.py +188 -0
- pylantir/auth_models.py +80 -0
- pylantir/auth_utils.py +210 -0
- pylantir/cli/run.py +322 -16
- pylantir/config/config_example_with_cors.json +108 -0
- pylantir/config/mwl_config.json +18 -0
- pylantir/db_concurrency.py +180 -0
- pylantir/db_setup.py +78 -3
- pylantir/redcap_to_db.py +225 -91
- pylantir-0.2.1.dist-info/METADATA +584 -0
- pylantir-0.2.1.dist-info/RECORD +20 -0
- pylantir-0.1.3.dist-info/METADATA +0 -193
- pylantir-0.1.3.dist-info/RECORD +0 -14
- {pylantir-0.1.3.dist-info → pylantir-0.2.1.dist-info}/WHEEL +0 -0
- {pylantir-0.1.3.dist-info → pylantir-0.2.1.dist-info}/entry_points.txt +0 -0
- {pylantir-0.1.3.dist-info → pylantir-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Author: Milton Camacho
|
|
5
|
+
Date: 2025-11-18
|
|
6
|
+
Database setup for authentication system.
|
|
7
|
+
Manages separate users database connection and session handling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from sqlalchemy import create_engine
|
|
14
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
15
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
lgr = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthDatabaseError(Exception):
|
|
22
|
+
"""Raised when authentication database operations fail."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_auth_database_url(users_db_path: Optional[str] = None) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Get authentication database URL from configuration, environment, or default location.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
users_db_path: Optional path to users database from configuration
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
str: SQLite database URL for authentication
|
|
35
|
+
"""
|
|
36
|
+
if users_db_path:
|
|
37
|
+
# Use explicitly provided users database path from configuration
|
|
38
|
+
auth_db_path = os.path.expanduser(users_db_path)
|
|
39
|
+
lgr.info(f"Using configured users database path: {auth_db_path}")
|
|
40
|
+
else:
|
|
41
|
+
# Fallback to environment variable or default location
|
|
42
|
+
auth_db_path_env = os.getenv("USERS_DB_PATH")
|
|
43
|
+
|
|
44
|
+
if auth_db_path_env:
|
|
45
|
+
auth_db_path = os.path.expanduser(auth_db_path_env)
|
|
46
|
+
lgr.info(f"Using environment users database path: {auth_db_path}")
|
|
47
|
+
else:
|
|
48
|
+
# Default: users.db in same directory as main database
|
|
49
|
+
main_db_path = os.getenv("DB_PATH", "~/Desktop/worklist.db")
|
|
50
|
+
main_db_path = os.path.expanduser(main_db_path)
|
|
51
|
+
|
|
52
|
+
db_dir = Path(main_db_path).parent
|
|
53
|
+
auth_db_path = db_dir / "users.db"
|
|
54
|
+
lgr.info(f"Using default users database path: {auth_db_path}")
|
|
55
|
+
|
|
56
|
+
return f"sqlite:///{auth_db_path}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Create authentication database engine
|
|
60
|
+
auth_engine = None
|
|
61
|
+
AuthSessionLocal = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def init_auth_database(users_db_path: Optional[str] = None) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Initialize authentication database engine and session factory.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
users_db_path: Optional path to users database from configuration
|
|
70
|
+
"""
|
|
71
|
+
global auth_engine, AuthSessionLocal
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
database_url = get_auth_database_url(users_db_path)
|
|
75
|
+
lgr.info(f"Initializing authentication database: {database_url}")
|
|
76
|
+
|
|
77
|
+
auth_engine = create_engine(
|
|
78
|
+
database_url,
|
|
79
|
+
connect_args={"check_same_thread": False}, # SQLite specific
|
|
80
|
+
echo=os.getenv("DB_ECHO", "False").lower() == "true"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
AuthSessionLocal = sessionmaker(
|
|
84
|
+
autocommit=False,
|
|
85
|
+
autoflush=False,
|
|
86
|
+
bind=auth_engine
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Create all tables
|
|
90
|
+
from .auth_models import AuthBase
|
|
91
|
+
AuthBase.metadata.create_all(bind=auth_engine)
|
|
92
|
+
|
|
93
|
+
lgr.info("Authentication database initialized successfully")
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
lgr.error(f"Failed to initialize authentication database: {e}")
|
|
97
|
+
raise AuthDatabaseError(f"Database initialization failed: {e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_auth_db() -> Session:
|
|
101
|
+
"""
|
|
102
|
+
Get authentication database session.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Session: SQLAlchemy session for authentication database
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
AuthDatabaseError: If database is not initialized
|
|
109
|
+
"""
|
|
110
|
+
if AuthSessionLocal is None:
|
|
111
|
+
init_auth_database()
|
|
112
|
+
|
|
113
|
+
if AuthSessionLocal is None:
|
|
114
|
+
raise AuthDatabaseError("Authentication database not initialized")
|
|
115
|
+
|
|
116
|
+
db = AuthSessionLocal()
|
|
117
|
+
try:
|
|
118
|
+
yield db
|
|
119
|
+
except SQLAlchemyError as e:
|
|
120
|
+
lgr.error(f"Database session error: {e}")
|
|
121
|
+
db.rollback()
|
|
122
|
+
raise AuthDatabaseError(f"Database operation failed: {e}")
|
|
123
|
+
finally:
|
|
124
|
+
db.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def create_initial_admin_user(users_db_path: Optional[str] = None) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Create initial admin user if no users exist in database.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
users_db_path: Optional path to users database from configuration
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
# Initialize database with correct path if not already done
|
|
136
|
+
if AuthSessionLocal is None:
|
|
137
|
+
init_auth_database(users_db_path)
|
|
138
|
+
|
|
139
|
+
db = next(get_auth_db())
|
|
140
|
+
|
|
141
|
+
from .auth_utils import create_admin_user
|
|
142
|
+
|
|
143
|
+
admin_user = create_admin_user(
|
|
144
|
+
db=db,
|
|
145
|
+
username="admin",
|
|
146
|
+
password="admin123", # Should be changed immediately
|
|
147
|
+
email="admin@localhost",
|
|
148
|
+
full_name="System Administrator"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if admin_user:
|
|
152
|
+
lgr.warning("Created default admin user with password 'admin123'. Please change this immediately!")
|
|
153
|
+
lgr.info("Use 'pylantir admin-password' command to change the admin password")
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
lgr.error(f"Failed to create initial admin user: {e}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def backup_auth_database(backup_path: Optional[str] = None) -> bool:
|
|
160
|
+
"""
|
|
161
|
+
Create backup of authentication database.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
backup_path: Optional custom backup path
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
bool: True if backup successful
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
database_url = get_auth_database_url()
|
|
171
|
+
source_path = database_url.replace("sqlite:///", "")
|
|
172
|
+
|
|
173
|
+
if backup_path is None:
|
|
174
|
+
# Create backup in same directory with timestamp
|
|
175
|
+
from datetime import datetime
|
|
176
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
177
|
+
backup_path = f"{source_path}.backup_{timestamp}"
|
|
178
|
+
|
|
179
|
+
# Copy database file
|
|
180
|
+
import shutil
|
|
181
|
+
shutil.copy2(source_path, backup_path)
|
|
182
|
+
|
|
183
|
+
lgr.info(f"Authentication database backed up to: {backup_path}")
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
lgr.error(f"Failed to backup authentication database: {e}")
|
|
188
|
+
return False
|
pylantir/auth_models.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Author: Milton Camacho
|
|
5
|
+
Date: 2025-11-18
|
|
6
|
+
This script provides the SQLAlchemy models for user authentication and authorization.
|
|
7
|
+
|
|
8
|
+
User roles:
|
|
9
|
+
- admin: Full access to users and worklist data (CRUD operations)
|
|
10
|
+
- write: Read and write access to worklist data only
|
|
11
|
+
- read: Read-only access to worklist data only
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from sqlalchemy.orm import declarative_base
|
|
15
|
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
import logging
|
|
18
|
+
import enum
|
|
19
|
+
|
|
20
|
+
AuthBase = declarative_base()
|
|
21
|
+
|
|
22
|
+
lgr = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UserRole(enum.Enum):
|
|
26
|
+
"""User role enumeration for access control."""
|
|
27
|
+
ADMIN = "admin"
|
|
28
|
+
WRITE = "write"
|
|
29
|
+
READ = "read"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class User(AuthBase):
|
|
33
|
+
"""User model for API authentication and authorization."""
|
|
34
|
+
|
|
35
|
+
__tablename__ = 'users'
|
|
36
|
+
|
|
37
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
38
|
+
username = Column(String(50), unique=True, nullable=False, index=True)
|
|
39
|
+
email = Column(String(100), unique=True, nullable=True)
|
|
40
|
+
full_name = Column(String(100), nullable=True)
|
|
41
|
+
hashed_password = Column(String(255), nullable=False)
|
|
42
|
+
role = Column(Enum(UserRole), nullable=False, default=UserRole.READ)
|
|
43
|
+
is_active = Column(Boolean, default=True)
|
|
44
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
45
|
+
last_login = Column(DateTime, nullable=True)
|
|
46
|
+
created_by = Column(Integer, nullable=True) # ID of user who created this account
|
|
47
|
+
|
|
48
|
+
def __repr__(self):
|
|
49
|
+
return f"<User(id={self.id}, username={self.username}, role={self.role.value}, active={self.is_active})>"
|
|
50
|
+
|
|
51
|
+
def has_permission(self, action: str, resource: str = "worklist") -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Check if user has permission for specific action on resource.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
action: Action type ('read', 'write', 'delete', 'create')
|
|
57
|
+
resource: Resource type ('worklist', 'users')
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
bool: True if user has permission
|
|
61
|
+
"""
|
|
62
|
+
if not self.is_active:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Admin has all permissions
|
|
66
|
+
if self.role == UserRole.ADMIN:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# Non-admin users cannot manage other users
|
|
70
|
+
if resource == "users":
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
# Worklist permissions based on role
|
|
74
|
+
if resource == "worklist":
|
|
75
|
+
if action == "read":
|
|
76
|
+
return self.role in [UserRole.READ, UserRole.WRITE, UserRole.ADMIN]
|
|
77
|
+
elif action in ["write", "create", "update", "delete"]:
|
|
78
|
+
return self.role in [UserRole.WRITE, UserRole.ADMIN]
|
|
79
|
+
|
|
80
|
+
return False
|
pylantir/auth_utils.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Author: Milton Camacho
|
|
5
|
+
Date: 2025-11-18
|
|
6
|
+
Authentication utilities for password hashing, JWT token generation,
|
|
7
|
+
and user authentication functions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Optional, Dict, Any
|
|
14
|
+
from passlib.context import CryptContext
|
|
15
|
+
from jose import JWTError, jwt
|
|
16
|
+
from sqlalchemy.orm import Session
|
|
17
|
+
|
|
18
|
+
lgr = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Password hashing context
|
|
21
|
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
22
|
+
|
|
23
|
+
# JWT configuration
|
|
24
|
+
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
|
|
25
|
+
ALGORITHM = "HS256"
|
|
26
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthenticationError(Exception):
|
|
30
|
+
"""Raised when authentication fails."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthorizationError(Exception):
|
|
35
|
+
"""Raised when user lacks required permissions."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Verify a plain password against a hashed password.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
plain_password: Plain text password
|
|
45
|
+
hashed_password: Hashed password from database
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
bool: True if password matches
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
return pwd_context.verify(plain_password, hashed_password)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
lgr.error(f"Password verification error: {e}")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_password_hash(password: str) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Hash a plain password using bcrypt.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
password: Plain text password
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
str: Hashed password
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
return pwd_context.hash(password)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
lgr.error(f"Password hashing error: {e}")
|
|
71
|
+
raise AuthenticationError("Failed to hash password")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Create a JWT access token.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
data: Data to encode in token (typically user info)
|
|
80
|
+
expires_delta: Token expiration time
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
str: JWT token
|
|
84
|
+
"""
|
|
85
|
+
to_encode = data.copy()
|
|
86
|
+
|
|
87
|
+
if expires_delta:
|
|
88
|
+
expire = datetime.utcnow() + expires_delta
|
|
89
|
+
else:
|
|
90
|
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
91
|
+
|
|
92
|
+
to_encode.update({"exp": expire})
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
96
|
+
return encoded_jwt
|
|
97
|
+
except Exception as e:
|
|
98
|
+
lgr.error(f"Token creation error: {e}")
|
|
99
|
+
raise AuthenticationError("Failed to create access token")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
|
103
|
+
"""
|
|
104
|
+
Verify and decode a JWT token.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
token: JWT token string
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dict containing token payload or None if invalid
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
114
|
+
return payload
|
|
115
|
+
except JWTError as e:
|
|
116
|
+
lgr.warning(f"Token verification failed: {e}")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def authenticate_user(db: Session, username: str, password: str):
|
|
121
|
+
"""
|
|
122
|
+
Authenticate user with username and password.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
db: Database session
|
|
126
|
+
username: Username
|
|
127
|
+
password: Plain text password
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
User object if authentication successful, None otherwise
|
|
131
|
+
"""
|
|
132
|
+
from .auth_models import User
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
user = db.query(User).filter(User.username == username).first()
|
|
136
|
+
|
|
137
|
+
if not user:
|
|
138
|
+
lgr.warning(f"Authentication attempt for non-existent user: {username}")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
if not user.is_active:
|
|
142
|
+
lgr.warning(f"Authentication attempt for inactive user: {username}")
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
if not verify_password(password, user.hashed_password):
|
|
146
|
+
lgr.warning(f"Failed password authentication for user: {username}")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
# Update last login time
|
|
150
|
+
user.last_login = datetime.utcnow()
|
|
151
|
+
db.commit()
|
|
152
|
+
|
|
153
|
+
lgr.info(f"Successful authentication for user: {username}")
|
|
154
|
+
return user
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
lgr.error(f"Authentication error for user {username}: {e}")
|
|
158
|
+
db.rollback()
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def create_admin_user(db: Session, username: str = "admin", password: str = "admin123",
|
|
163
|
+
email: str = "admin@localhost", full_name: str = "System Administrator"):
|
|
164
|
+
"""
|
|
165
|
+
Create initial admin user if no users exist.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
db: Database session
|
|
169
|
+
username: Admin username
|
|
170
|
+
password: Admin password
|
|
171
|
+
email: Admin email
|
|
172
|
+
full_name: Admin full name
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
User object or None if creation fails
|
|
176
|
+
"""
|
|
177
|
+
from .auth_models import User, UserRole
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
# Check if any users exist
|
|
181
|
+
user_count = db.query(User).count()
|
|
182
|
+
|
|
183
|
+
if user_count > 0:
|
|
184
|
+
lgr.info("Users already exist, skipping admin user creation")
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# Create admin user
|
|
188
|
+
hashed_password = get_password_hash(password)
|
|
189
|
+
|
|
190
|
+
admin_user = User(
|
|
191
|
+
username=username,
|
|
192
|
+
email=email,
|
|
193
|
+
full_name=full_name,
|
|
194
|
+
hashed_password=hashed_password,
|
|
195
|
+
role=UserRole.ADMIN,
|
|
196
|
+
is_active=True,
|
|
197
|
+
created_at=datetime.utcnow()
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
db.add(admin_user)
|
|
201
|
+
db.commit()
|
|
202
|
+
db.refresh(admin_user)
|
|
203
|
+
|
|
204
|
+
lgr.info(f"Created initial admin user: {username}")
|
|
205
|
+
return admin_user
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
lgr.error(f"Failed to create admin user: {e}")
|
|
209
|
+
db.rollback()
|
|
210
|
+
return None
|