putplace 0.4.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.
Potentially problematic release.
This version of putplace might be problematic. Click here for more details.
- putplace/__init__.py +5 -0
- putplace/auth.py +279 -0
- putplace/config.py +167 -0
- putplace/database.py +387 -0
- putplace/main.py +3048 -0
- putplace/models.py +179 -0
- putplace/ppclient.py +586 -0
- putplace/ppserver.py +453 -0
- putplace/scripts/__init__.py +1 -0
- putplace/scripts/create_api_key.py +119 -0
- putplace/storage.py +456 -0
- putplace/user_auth.py +52 -0
- putplace/version.py +6 -0
- putplace-0.4.1.dist-info/METADATA +346 -0
- putplace-0.4.1.dist-info/RECORD +17 -0
- putplace-0.4.1.dist-info/WHEEL +4 -0
- putplace-0.4.1.dist-info/entry_points.txt +3 -0
putplace/__init__.py
ADDED
putplace/auth.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Authentication and authorization for PutPlace API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, HTTPException, Security, status
|
|
9
|
+
from fastapi.security import APIKeyHeader
|
|
10
|
+
from pymongo.asynchronous.collection import AsyncCollection
|
|
11
|
+
|
|
12
|
+
from . import database
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .database import MongoDB
|
|
16
|
+
|
|
17
|
+
# API key header name
|
|
18
|
+
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_auth_db() -> "MongoDB":
|
|
22
|
+
"""Get database instance for authentication.
|
|
23
|
+
|
|
24
|
+
This function is used as a dependency in FastAPI routes.
|
|
25
|
+
Returns the global database.mongodb instance.
|
|
26
|
+
"""
|
|
27
|
+
return database.mongodb
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_api_key() -> str:
|
|
31
|
+
"""Generate a new API key.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A cryptographically secure random API key (hex string, 64 characters)
|
|
35
|
+
"""
|
|
36
|
+
return secrets.token_hex(32) # 32 bytes = 64 hex characters
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hash_api_key(api_key: str) -> str:
|
|
40
|
+
"""Hash an API key for secure storage.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
api_key: The API key to hash
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
SHA256 hash of the API key
|
|
47
|
+
"""
|
|
48
|
+
return hashlib.sha256(api_key.encode()).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIKeyAuth:
|
|
52
|
+
"""API Key authentication manager."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, db: database.MongoDB):
|
|
55
|
+
"""Initialize API key authentication.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
db: MongoDB database instance
|
|
59
|
+
"""
|
|
60
|
+
self.db = db
|
|
61
|
+
|
|
62
|
+
async def get_api_keys_collection(self) -> AsyncCollection:
|
|
63
|
+
"""Get the API keys collection.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
MongoDB collection for API keys
|
|
67
|
+
"""
|
|
68
|
+
if self.db.client is None:
|
|
69
|
+
raise RuntimeError("Database not connected")
|
|
70
|
+
|
|
71
|
+
db = self.db.client[self.db.collection.database.name]
|
|
72
|
+
return db["api_keys"]
|
|
73
|
+
|
|
74
|
+
async def create_api_key(
|
|
75
|
+
self,
|
|
76
|
+
name: str,
|
|
77
|
+
user_id: Optional[str] = None,
|
|
78
|
+
description: Optional[str] = None,
|
|
79
|
+
) -> tuple[str, dict]:
|
|
80
|
+
"""Create a new API key.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
name: Name/identifier for this API key
|
|
84
|
+
user_id: Optional user ID who owns this key
|
|
85
|
+
description: Optional description
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (api_key, key_metadata)
|
|
89
|
+
The api_key is returned only once and should be given to the user.
|
|
90
|
+
The key_metadata contains the stored information (without the actual key).
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
RuntimeError: If database not connected
|
|
94
|
+
"""
|
|
95
|
+
collection = await self.get_api_keys_collection()
|
|
96
|
+
|
|
97
|
+
# Generate new API key
|
|
98
|
+
api_key = generate_api_key()
|
|
99
|
+
key_hash = hash_api_key(api_key)
|
|
100
|
+
|
|
101
|
+
# Create metadata document
|
|
102
|
+
key_doc = {
|
|
103
|
+
"key_hash": key_hash,
|
|
104
|
+
"name": name,
|
|
105
|
+
"description": description,
|
|
106
|
+
"user_id": user_id, # Associate with user
|
|
107
|
+
"created_at": datetime.utcnow(),
|
|
108
|
+
"last_used_at": None,
|
|
109
|
+
"is_active": True,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Insert into database
|
|
113
|
+
result = await collection.insert_one(key_doc.copy())
|
|
114
|
+
|
|
115
|
+
# Return the plain API key (only time we show it) and metadata
|
|
116
|
+
key_doc["_id"] = str(result.inserted_id)
|
|
117
|
+
key_doc.pop("key_hash") # Don't include hash in response
|
|
118
|
+
|
|
119
|
+
return api_key, key_doc
|
|
120
|
+
|
|
121
|
+
async def verify_api_key(self, api_key: str) -> Optional[dict]:
|
|
122
|
+
"""Verify an API key and return its metadata.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
api_key: The API key to verify
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Key metadata if valid and active, None otherwise
|
|
129
|
+
"""
|
|
130
|
+
collection = await self.get_api_keys_collection()
|
|
131
|
+
|
|
132
|
+
# Hash the provided key
|
|
133
|
+
key_hash = hash_api_key(api_key)
|
|
134
|
+
|
|
135
|
+
# Look up in database
|
|
136
|
+
key_doc = await collection.find_one({
|
|
137
|
+
"key_hash": key_hash,
|
|
138
|
+
"is_active": True,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if key_doc:
|
|
142
|
+
# Update last used timestamp
|
|
143
|
+
await collection.update_one(
|
|
144
|
+
{"_id": key_doc["_id"]},
|
|
145
|
+
{"$set": {"last_used_at": datetime.utcnow()}}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Return metadata (without hash)
|
|
149
|
+
key_doc.pop("key_hash", None)
|
|
150
|
+
key_doc["_id"] = str(key_doc["_id"])
|
|
151
|
+
return key_doc
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
async def revoke_api_key(self, key_id: str) -> bool:
|
|
156
|
+
"""Revoke (deactivate) an API key.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
key_id: MongoDB ObjectId of the key to revoke
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if key was revoked, False if not found
|
|
163
|
+
"""
|
|
164
|
+
from bson import ObjectId
|
|
165
|
+
|
|
166
|
+
collection = await self.get_api_keys_collection()
|
|
167
|
+
|
|
168
|
+
result = await collection.update_one(
|
|
169
|
+
{"_id": ObjectId(key_id)},
|
|
170
|
+
{"$set": {"is_active": False}}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return result.modified_count > 0
|
|
174
|
+
|
|
175
|
+
async def list_api_keys(self, user_id: Optional[str] = None) -> list[dict]:
|
|
176
|
+
"""List all API keys (without showing actual keys).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
user_id: Optional user ID to filter keys by owner
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of API key metadata
|
|
183
|
+
"""
|
|
184
|
+
collection = await self.get_api_keys_collection()
|
|
185
|
+
|
|
186
|
+
# Build query filter
|
|
187
|
+
query = {}
|
|
188
|
+
if user_id:
|
|
189
|
+
query["user_id"] = user_id
|
|
190
|
+
|
|
191
|
+
cursor = collection.find(query, {"key_hash": 0})
|
|
192
|
+
keys = []
|
|
193
|
+
|
|
194
|
+
async for key_doc in cursor:
|
|
195
|
+
key_doc["_id"] = str(key_doc["_id"])
|
|
196
|
+
keys.append(key_doc)
|
|
197
|
+
|
|
198
|
+
return keys
|
|
199
|
+
|
|
200
|
+
async def delete_api_key(self, key_id: str) -> bool:
|
|
201
|
+
"""Permanently delete an API key.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
key_id: MongoDB ObjectId of the key to delete
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if key was deleted, False if not found
|
|
208
|
+
"""
|
|
209
|
+
from bson import ObjectId
|
|
210
|
+
|
|
211
|
+
collection = await self.get_api_keys_collection()
|
|
212
|
+
|
|
213
|
+
result = await collection.delete_one({"_id": ObjectId(key_id)})
|
|
214
|
+
|
|
215
|
+
return result.deleted_count > 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Dependency for protected endpoints
|
|
219
|
+
async def get_current_api_key(
|
|
220
|
+
api_key: str = Security(API_KEY_HEADER),
|
|
221
|
+
db: "MongoDB" = Depends(get_auth_db),
|
|
222
|
+
) -> dict:
|
|
223
|
+
"""FastAPI dependency to validate API key.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
api_key: API key from request header
|
|
227
|
+
db: Database instance (injected)
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
API key metadata if valid
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
HTTPException: If API key is missing or invalid
|
|
234
|
+
"""
|
|
235
|
+
if not api_key:
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
238
|
+
detail="API key required. Include X-API-Key header.",
|
|
239
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Get API key authenticator
|
|
243
|
+
auth = APIKeyAuth(db)
|
|
244
|
+
|
|
245
|
+
# Verify the key
|
|
246
|
+
key_metadata = await auth.verify_api_key(api_key)
|
|
247
|
+
|
|
248
|
+
if not key_metadata:
|
|
249
|
+
raise HTTPException(
|
|
250
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
251
|
+
detail="Invalid or inactive API key",
|
|
252
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return key_metadata
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Optional dependency - allows unauthenticated access
|
|
259
|
+
async def get_optional_api_key(
|
|
260
|
+
api_key: str = Security(API_KEY_HEADER),
|
|
261
|
+
db: "MongoDB" = Depends(get_auth_db),
|
|
262
|
+
) -> Optional[dict]:
|
|
263
|
+
"""FastAPI dependency for optional API key authentication.
|
|
264
|
+
|
|
265
|
+
Returns API key metadata if provided and valid, None otherwise.
|
|
266
|
+
Does not raise an error if no key is provided.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
api_key: API key from request header
|
|
270
|
+
db: Database instance (injected)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
API key metadata if valid, None if not provided or invalid
|
|
274
|
+
"""
|
|
275
|
+
if not api_key:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
auth = APIKeyAuth(db)
|
|
279
|
+
return await auth.verify_api_key(api_key)
|
putplace/config.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Application configuration.
|
|
2
|
+
|
|
3
|
+
Configuration priority (highest to lowest):
|
|
4
|
+
1. Environment variables
|
|
5
|
+
2. ppserver.toml file
|
|
6
|
+
3. Default values
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
from .version import __version__
|
|
16
|
+
|
|
17
|
+
# Import tomli for Python < 3.11, tomllib for Python >= 3.11
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
import tomllib
|
|
20
|
+
else:
|
|
21
|
+
try:
|
|
22
|
+
import tomli as tomllib
|
|
23
|
+
except ImportError:
|
|
24
|
+
tomllib = None # type: ignore
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_config_file() -> Optional[Path]:
|
|
28
|
+
"""Find ppserver.toml file in standard locations.
|
|
29
|
+
|
|
30
|
+
Search order:
|
|
31
|
+
1. ./ppserver.toml (current directory)
|
|
32
|
+
2. ~/.config/putplace/ppserver.toml (user config)
|
|
33
|
+
3. /etc/putplace/ppserver.toml (system config)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to config file if found, None otherwise
|
|
37
|
+
"""
|
|
38
|
+
search_paths = [
|
|
39
|
+
Path.cwd() / "ppserver.toml",
|
|
40
|
+
Path.home() / ".config" / "putplace" / "ppserver.toml",
|
|
41
|
+
Path("/etc/putplace/ppserver.toml"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
for path in search_paths:
|
|
45
|
+
if path.exists() and path.is_file():
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_toml_config() -> dict[str, Any]:
|
|
52
|
+
"""Load configuration from TOML file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dictionary with configuration values, empty dict if no config file found
|
|
56
|
+
"""
|
|
57
|
+
if tomllib is None:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
config_file = find_config_file()
|
|
61
|
+
if config_file is None:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with open(config_file, "rb") as f:
|
|
66
|
+
toml_data = tomllib.load(f)
|
|
67
|
+
|
|
68
|
+
# Flatten nested TOML structure to match Settings field names
|
|
69
|
+
config = {}
|
|
70
|
+
|
|
71
|
+
# Database settings
|
|
72
|
+
if "database" in toml_data:
|
|
73
|
+
db = toml_data["database"]
|
|
74
|
+
if "mongodb_url" in db:
|
|
75
|
+
config["mongodb_url"] = db["mongodb_url"]
|
|
76
|
+
if "mongodb_database" in db:
|
|
77
|
+
config["mongodb_database"] = db["mongodb_database"]
|
|
78
|
+
if "mongodb_collection" in db:
|
|
79
|
+
config["mongodb_collection"] = db["mongodb_collection"]
|
|
80
|
+
|
|
81
|
+
# API settings
|
|
82
|
+
if "api" in toml_data:
|
|
83
|
+
api = toml_data["api"]
|
|
84
|
+
if "title" in api:
|
|
85
|
+
config["api_title"] = api["title"]
|
|
86
|
+
if "description" in api:
|
|
87
|
+
config["api_description"] = api["description"]
|
|
88
|
+
|
|
89
|
+
# Storage settings
|
|
90
|
+
if "storage" in toml_data:
|
|
91
|
+
storage = toml_data["storage"]
|
|
92
|
+
if "backend" in storage:
|
|
93
|
+
config["storage_backend"] = storage["backend"]
|
|
94
|
+
if "path" in storage:
|
|
95
|
+
config["storage_path"] = storage["path"]
|
|
96
|
+
if "s3_bucket_name" in storage:
|
|
97
|
+
config["s3_bucket_name"] = storage["s3_bucket_name"]
|
|
98
|
+
if "s3_region_name" in storage:
|
|
99
|
+
config["s3_region_name"] = storage["s3_region_name"]
|
|
100
|
+
if "s3_prefix" in storage:
|
|
101
|
+
config["s3_prefix"] = storage["s3_prefix"]
|
|
102
|
+
|
|
103
|
+
# AWS settings
|
|
104
|
+
if "aws" in toml_data:
|
|
105
|
+
aws = toml_data["aws"]
|
|
106
|
+
if "profile" in aws:
|
|
107
|
+
config["aws_profile"] = aws["profile"]
|
|
108
|
+
if "access_key_id" in aws:
|
|
109
|
+
config["aws_access_key_id"] = aws["access_key_id"]
|
|
110
|
+
if "secret_access_key" in aws:
|
|
111
|
+
config["aws_secret_access_key"] = aws["secret_access_key"]
|
|
112
|
+
|
|
113
|
+
return config
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
# If there's an error reading TOML, just return empty config
|
|
117
|
+
# Environment variables and defaults will still work
|
|
118
|
+
import logging
|
|
119
|
+
logging.warning(f"Failed to load TOML config from {config_file}: {e}")
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Settings(BaseSettings):
|
|
124
|
+
"""Application settings.
|
|
125
|
+
|
|
126
|
+
Configuration is loaded in this priority order (highest to lowest):
|
|
127
|
+
1. Environment variables (e.g., MONGODB_URL, STORAGE_BACKEND)
|
|
128
|
+
2. ppserver.toml file (./ppserver.toml, ~/.config/putplace/ppserver.toml, /etc/putplace/ppserver.toml)
|
|
129
|
+
3. Default values defined below
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
mongodb_url: str = "mongodb://localhost:27017"
|
|
133
|
+
mongodb_database: str = "putplace"
|
|
134
|
+
mongodb_collection: str = "file_metadata"
|
|
135
|
+
|
|
136
|
+
# API settings
|
|
137
|
+
api_title: str = "PutPlace API"
|
|
138
|
+
api_version: str = __version__
|
|
139
|
+
api_description: str = "File metadata storage API"
|
|
140
|
+
|
|
141
|
+
# Storage settings
|
|
142
|
+
storage_backend: str = "local" # "local" or "s3"
|
|
143
|
+
storage_path: str = "/var/putplace/files" # For local storage
|
|
144
|
+
|
|
145
|
+
# S3 storage settings (only used if storage_backend="s3")
|
|
146
|
+
s3_bucket_name: Optional[str] = None
|
|
147
|
+
s3_region_name: str = "us-east-1"
|
|
148
|
+
s3_prefix: str = "files/"
|
|
149
|
+
|
|
150
|
+
# AWS credentials (OPTIONAL - see SECURITY.md for best practices)
|
|
151
|
+
# If not specified, boto3 will use standard credential chain:
|
|
152
|
+
# 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
153
|
+
# 2. AWS credentials file (~/.aws/credentials)
|
|
154
|
+
# 3. IAM role (if running on EC2/ECS/Lambda) - RECOMMENDED
|
|
155
|
+
aws_profile: Optional[str] = None # Use specific profile from ~/.aws/credentials
|
|
156
|
+
aws_access_key_id: Optional[str] = None # NOT RECOMMENDED: use IAM roles or profiles instead
|
|
157
|
+
aws_secret_access_key: Optional[str] = None # NOT RECOMMENDED: use IAM roles or profiles instead
|
|
158
|
+
|
|
159
|
+
model_config = SettingsConfigDict(
|
|
160
|
+
case_sensitive=False,
|
|
161
|
+
extra="ignore", # Ignore extra environment variables (e.g., PUTPLACE_API_KEY for client)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Load TOML config first, then create Settings (env vars will override)
|
|
166
|
+
_toml_config = load_toml_config()
|
|
167
|
+
settings = Settings(**_toml_config)
|