airflow-file-auth-manager 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.
@@ -0,0 +1,37 @@
1
+ """File-based Auth Manager for Apache Airflow 3.x.
2
+
3
+ A lightweight YAML file-based authentication manager that supports:
4
+ - bcrypt password hashing
5
+ - Three-tier role system (admin, editor, viewer)
6
+ - JWT token-based session management
7
+ """
8
+
9
+ from airflow_file_auth_manager.password import (
10
+ PasswordPolicyError,
11
+ hash_password,
12
+ validate_password,
13
+ verify_password,
14
+ )
15
+ from airflow_file_auth_manager.policy import FileAuthPolicy, Role
16
+ from airflow_file_auth_manager.user import FileUser
17
+ from airflow_file_auth_manager.user_store import UserStore
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ # FileAuthManager requires Airflow, so import lazily
22
+ try:
23
+ from airflow_file_auth_manager.file_auth_manager import FileAuthManager
24
+ except ImportError:
25
+ FileAuthManager = None # type: ignore[misc, assignment]
26
+
27
+ __all__ = [
28
+ "FileAuthManager",
29
+ "FileAuthPolicy",
30
+ "FileUser",
31
+ "PasswordPolicyError",
32
+ "Role",
33
+ "UserStore",
34
+ "hash_password",
35
+ "validate_password",
36
+ "verify_password",
37
+ ]
@@ -0,0 +1,266 @@
1
+ """CLI commands for managing file auth users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from airflow_file_auth_manager.password import hash_password
12
+ from airflow_file_auth_manager.user_store import UserStore
13
+
14
+ if TYPE_CHECKING:
15
+ pass
16
+
17
+
18
+ def add_user(args: argparse.Namespace) -> None:
19
+ """Add a new user to the users file."""
20
+ store = UserStore(args.file)
21
+
22
+ # Get password interactively if not provided
23
+ password = args.password
24
+ if not password:
25
+ password = getpass.getpass("Password: ")
26
+ confirm = getpass.getpass("Confirm password: ")
27
+ if password != confirm:
28
+ print("Error: Passwords do not match", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ if not password:
32
+ print("Error: Password cannot be empty", file=sys.stderr)
33
+ sys.exit(1)
34
+
35
+ try:
36
+ user = store.add_user(
37
+ username=args.username,
38
+ password=password,
39
+ role=args.role,
40
+ email=args.email or "",
41
+ first_name=args.firstname or "",
42
+ last_name=args.lastname or "",
43
+ )
44
+ store.save()
45
+ print(f"User '{user.username}' added with role '{user.role}'")
46
+ except ValueError as e:
47
+ print(f"Error: {e}", file=sys.stderr)
48
+ sys.exit(1)
49
+
50
+
51
+ def update_user(args: argparse.Namespace) -> None:
52
+ """Update an existing user."""
53
+ store = UserStore(args.file)
54
+
55
+ # Get password interactively if flag is set
56
+ password = None
57
+ if args.password:
58
+ password = getpass.getpass("New password: ")
59
+ confirm = getpass.getpass("Confirm password: ")
60
+ if password != confirm:
61
+ print("Error: Passwords do not match", file=sys.stderr)
62
+ sys.exit(1)
63
+
64
+ try:
65
+ user = store.update_user(
66
+ username=args.username,
67
+ password=password,
68
+ role=args.role,
69
+ email=args.email,
70
+ first_name=args.firstname,
71
+ last_name=args.lastname,
72
+ active=args.active,
73
+ )
74
+ store.save()
75
+ print(f"User '{user.username}' updated")
76
+ except ValueError as e:
77
+ print(f"Error: {e}", file=sys.stderr)
78
+ sys.exit(1)
79
+
80
+
81
+ def delete_user(args: argparse.Namespace) -> None:
82
+ """Delete a user."""
83
+ store = UserStore(args.file)
84
+
85
+ if not args.yes:
86
+ confirm = input(f"Delete user '{args.username}'? [y/N]: ")
87
+ if confirm.lower() != "y":
88
+ print("Aborted")
89
+ return
90
+
91
+ try:
92
+ store.delete_user(args.username)
93
+ store.save()
94
+ print(f"User '{args.username}' deleted")
95
+ except ValueError as e:
96
+ print(f"Error: {e}", file=sys.stderr)
97
+ sys.exit(1)
98
+
99
+
100
+ def list_users(args: argparse.Namespace) -> None:
101
+ """List all users."""
102
+ store = UserStore(args.file)
103
+ users = store.get_all_users()
104
+
105
+ if not users:
106
+ print("No users found")
107
+ return
108
+
109
+ # Table header
110
+ print(f"{'Username':<20} {'Role':<10} {'Email':<30} {'Active':<8}")
111
+ print("-" * 70)
112
+
113
+ for user in users:
114
+ active = "Yes" if user.is_active else "No"
115
+ print(f"{user.username:<20} {user.role:<10} {user.email:<30} {active:<8}")
116
+
117
+ print(f"\nTotal: {len(users)} user(s)")
118
+
119
+
120
+ def hash_password_cmd(args: argparse.Namespace) -> None:
121
+ """Generate a password hash."""
122
+ password = args.password
123
+ if not password:
124
+ password = getpass.getpass("Password: ")
125
+
126
+ if not password:
127
+ print("Error: Password cannot be empty", file=sys.stderr)
128
+ sys.exit(1)
129
+
130
+ hashed = hash_password(password)
131
+ print(hashed)
132
+
133
+
134
+ def init_file(args: argparse.Namespace) -> None:
135
+ """Initialize a new users file with an admin user."""
136
+ file_path = Path(args.file)
137
+
138
+ if file_path.exists() and not args.force:
139
+ print(f"Error: File already exists: {file_path}", file=sys.stderr)
140
+ print("Use --force to overwrite", file=sys.stderr)
141
+ sys.exit(1)
142
+
143
+ # Get admin password
144
+ password = args.password
145
+ if not password:
146
+ password = getpass.getpass("Admin password: ")
147
+ confirm = getpass.getpass("Confirm password: ")
148
+ if password != confirm:
149
+ print("Error: Passwords do not match", file=sys.stderr)
150
+ sys.exit(1)
151
+
152
+ if not password:
153
+ print("Error: Password cannot be empty", file=sys.stderr)
154
+ sys.exit(1)
155
+
156
+ store = UserStore(file_path)
157
+ store.add_user(
158
+ username="admin",
159
+ password=password,
160
+ role="admin",
161
+ email=args.email or "",
162
+ )
163
+ store.save()
164
+ print(f"Created users file: {file_path}")
165
+ print("Admin user created with username 'admin'")
166
+
167
+
168
+ def create_parser() -> argparse.ArgumentParser:
169
+ """Create the argument parser."""
170
+ parser = argparse.ArgumentParser(
171
+ prog="airflow file-auth",
172
+ description="Manage file-based authentication users",
173
+ )
174
+
175
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
176
+
177
+ # add-user command
178
+ add_parser = subparsers.add_parser("add-user", help="Add a new user")
179
+ add_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
180
+ add_parser.add_argument("-u", "--username", required=True, help="Username")
181
+ add_parser.add_argument("-p", "--password", help="Password (will prompt if not provided)")
182
+ add_parser.add_argument(
183
+ "-r", "--role",
184
+ required=True,
185
+ choices=["admin", "editor", "viewer"],
186
+ help="User role",
187
+ )
188
+ add_parser.add_argument("-e", "--email", help="Email address")
189
+ add_parser.add_argument("--firstname", help="First name")
190
+ add_parser.add_argument("--lastname", help="Last name")
191
+ add_parser.set_defaults(func=add_user)
192
+
193
+ # update-user command
194
+ update_parser = subparsers.add_parser("update-user", help="Update a user")
195
+ update_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
196
+ update_parser.add_argument("-u", "--username", required=True, help="Username to update")
197
+ update_parser.add_argument("-p", "--password", action="store_true", help="Change password")
198
+ update_parser.add_argument(
199
+ "-r", "--role",
200
+ choices=["admin", "editor", "viewer"],
201
+ help="New role",
202
+ )
203
+ update_parser.add_argument("-e", "--email", help="New email")
204
+ update_parser.add_argument("--firstname", help="New first name")
205
+ update_parser.add_argument("--lastname", help="New last name")
206
+ update_parser.add_argument("--active", type=lambda x: x.lower() == "true", help="Set active status (true/false)")
207
+ update_parser.set_defaults(func=update_user)
208
+
209
+ # delete-user command
210
+ delete_parser = subparsers.add_parser("delete-user", help="Delete a user")
211
+ delete_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
212
+ delete_parser.add_argument("-u", "--username", required=True, help="Username to delete")
213
+ delete_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
214
+ delete_parser.set_defaults(func=delete_user)
215
+
216
+ # list-users command
217
+ list_parser = subparsers.add_parser("list-users", help="List all users")
218
+ list_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
219
+ list_parser.set_defaults(func=list_users)
220
+
221
+ # hash-password command
222
+ hash_parser = subparsers.add_parser("hash-password", help="Generate bcrypt password hash")
223
+ hash_parser.add_argument("-p", "--password", help="Password to hash (will prompt if not provided)")
224
+ hash_parser.set_defaults(func=hash_password_cmd)
225
+
226
+ # init command
227
+ init_parser = subparsers.add_parser("init", help="Initialize users file with admin user")
228
+ init_parser.add_argument("-f", "--file", required=True, help="Path for new users YAML file")
229
+ init_parser.add_argument("-p", "--password", help="Admin password (will prompt if not provided)")
230
+ init_parser.add_argument("-e", "--email", help="Admin email")
231
+ init_parser.add_argument("--force", action="store_true", help="Overwrite existing file")
232
+ init_parser.set_defaults(func=init_file)
233
+
234
+ return parser
235
+
236
+
237
+ def main(args: list[str] | None = None) -> None:
238
+ """Main entry point for CLI."""
239
+ parser = create_parser()
240
+ parsed_args = parser.parse_args(args)
241
+
242
+ if not parsed_args.command:
243
+ parser.print_help()
244
+ sys.exit(1)
245
+
246
+ parsed_args.func(parsed_args)
247
+
248
+
249
+ # Airflow plugin for CLI integration (optional)
250
+ try:
251
+ from airflow.plugins_manager import AirflowPlugin
252
+
253
+ class FileAuthCLIPlugin(AirflowPlugin):
254
+ """Airflow plugin to register file-auth CLI commands."""
255
+
256
+ name = "file_auth_cli"
257
+ # Note: Airflow 3.x CLI plugin registration differs from 2.x
258
+ # For now, the CLI can be used directly via `python -m airflow_file_auth_manager.cli`
259
+
260
+ except ImportError:
261
+ # Airflow not installed, CLI can still be used standalone
262
+ pass
263
+
264
+
265
+ if __name__ == "__main__":
266
+ main()
@@ -0,0 +1,174 @@
1
+ """FastAPI endpoints for file-based authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated
8
+
9
+ from fastapi import FastAPI, Form, Request, Response
10
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from jinja2 import Environment, FileSystemLoader
13
+
14
+ from airflow.configuration import conf
15
+
16
+ from airflow_file_auth_manager.password import verify_password
17
+
18
+ if TYPE_CHECKING:
19
+ from airflow_file_auth_manager.file_auth_manager import FileAuthManager
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Template and static file paths
24
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
25
+ STATIC_DIR = Path(__file__).parent / "static"
26
+
27
+
28
+ def create_auth_app(auth_manager: FileAuthManager) -> FastAPI:
29
+ """Create FastAPI app with authentication endpoints.
30
+
31
+ Args:
32
+ auth_manager: The FileAuthManager instance.
33
+
34
+ Returns:
35
+ FastAPI app with /login, /token, and /logout endpoints.
36
+ """
37
+ app = FastAPI(
38
+ title="File Auth Manager",
39
+ description="YAML file-based authentication for Apache Airflow",
40
+ )
41
+
42
+ # Setup Jinja2 templates
43
+ jinja_env = Environment(
44
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
45
+ autoescape=True,
46
+ )
47
+
48
+ # Mount static files if directory exists
49
+ if STATIC_DIR.exists():
50
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
51
+
52
+ # Get JWT expiration from config
53
+ jwt_expiration = conf.getint("api_auth", "jwt_expiration_seconds", fallback=36000)
54
+
55
+ @app.get("/login", response_class=HTMLResponse)
56
+ async def login_page(request: Request, next: str | None = None, error: str | None = None) -> HTMLResponse:
57
+ """Render the login page."""
58
+ template = jinja_env.get_template("login.html")
59
+ html = template.render(
60
+ next_url=next or "/",
61
+ error=error,
62
+ )
63
+ return HTMLResponse(content=html)
64
+
65
+ @app.post("/token")
66
+ async def create_token(
67
+ request: Request,
68
+ response: Response,
69
+ username: Annotated[str | None, Form()] = None,
70
+ password: Annotated[str | None, Form()] = None,
71
+ ) -> Response:
72
+ """Authenticate user and create JWT token.
73
+
74
+ Supports both form submission (browser) and JSON API requests.
75
+
76
+ Security features:
77
+ - Browser sessions: HttpOnly cookies (protected from XSS)
78
+ - API clients: Bearer tokens in response body (use Authorization header)
79
+ """
80
+ # Handle JSON request
81
+ content_type = request.headers.get("content-type", "")
82
+ is_form_submission = "application/x-www-form-urlencoded" in content_type
83
+
84
+ if "application/json" in content_type:
85
+ try:
86
+ body = await request.json()
87
+ username = body.get("username")
88
+ password = body.get("password")
89
+ except Exception:
90
+ return JSONResponse(
91
+ status_code=400,
92
+ content={"error": "Invalid JSON body"},
93
+ )
94
+
95
+ # Validate input
96
+ if not username or not password:
97
+ logger.warning("AUDIT: Login attempt with missing credentials")
98
+ if is_form_submission:
99
+ return RedirectResponse(
100
+ url="/auth/file/login?error=Username+and+password+required",
101
+ status_code=303,
102
+ )
103
+ return JSONResponse(
104
+ status_code=400,
105
+ content={"error": "Username and password required"},
106
+ )
107
+
108
+ # Authenticate user
109
+ user = auth_manager.user_store.authenticate(username, password)
110
+ if not user:
111
+ logger.warning("AUDIT: Failed login attempt for user: %s (IP: %s)",
112
+ username, request.client.host if request.client else "unknown")
113
+ if is_form_submission:
114
+ return RedirectResponse(
115
+ url="/auth/file/login?error=Invalid+username+or+password",
116
+ status_code=303,
117
+ )
118
+ return JSONResponse(
119
+ status_code=401,
120
+ content={"error": "Invalid username or password"},
121
+ )
122
+
123
+ # Create JWT token
124
+ from airflow.api_fastapi.auth.tokens import create_jwt_token
125
+
126
+ token_payload = auth_manager.serialize_user(user)
127
+ token = create_jwt_token(token_payload, expiration_seconds=jwt_expiration)
128
+
129
+ logger.info("AUDIT: User logged in: %s (IP: %s)",
130
+ username, request.client.host if request.client else "unknown")
131
+
132
+ # Form submission - set HttpOnly cookie and redirect
133
+ if is_form_submission:
134
+ form_data = await request.form()
135
+ next_url = form_data.get("next", "/")
136
+
137
+ # Detect if using HTTPS
138
+ is_secure = (
139
+ request.url.scheme == "https"
140
+ or request.headers.get("x-forwarded-proto") == "https"
141
+ )
142
+
143
+ redirect_response = RedirectResponse(url=str(next_url), status_code=303)
144
+ redirect_response.set_cookie(
145
+ key="airflow_jwt",
146
+ value=token,
147
+ max_age=jwt_expiration,
148
+ httponly=True, # Protect from XSS attacks
149
+ secure=is_secure,
150
+ samesite="lax",
151
+ )
152
+ return redirect_response
153
+
154
+ # JSON API - return token in response body
155
+ # Client should use this token in Authorization header: "Bearer <token>"
156
+ return JSONResponse(
157
+ content={
158
+ "access_token": token,
159
+ "token_type": "Bearer",
160
+ "expires_in": jwt_expiration,
161
+ }
162
+ )
163
+
164
+ @app.get("/logout")
165
+ async def logout(request: Request) -> RedirectResponse:
166
+ """Log out user by clearing JWT cookie."""
167
+ logger.info("AUDIT: User logged out (IP: %s)",
168
+ request.client.host if request.client else "unknown")
169
+
170
+ redirect_response = RedirectResponse(url="/auth/file/login", status_code=303)
171
+ redirect_response.delete_cookie(key="airflow_jwt")
172
+ return redirect_response
173
+
174
+ return app