pki: admin can list/delete any paste

Add is_admin() helper to check if current user is admin.
Update DELETE /<id> to allow admin to delete any paste.
Update GET /pastes to support ?all=1 for admin to list all pastes.
Admin view includes owner fingerprint in paste metadata.
This commit is contained in:
Username
2025-12-21 21:30:50 +01:00
parent 2acf640d91
commit 40873434c3
2 changed files with 159 additions and 19 deletions

View File

@@ -376,6 +376,25 @@ def get_client_id() -> str | None:
return fingerprint
def is_admin() -> bool:
"""Check if current authenticated user is an admin.
Returns True only if:
- User has a valid client certificate
- Certificate is marked as admin in the PKI database
"""
client_id = get_client_id()
if not client_id:
return False
if not current_app.config.get("PKI_ENABLED"):
return False
from app.pki import is_admin_certificate
return is_admin_certificate(client_id)
# ─────────────────────────────────────────────────────────────────────────────
# Proof-of-Work
# ─────────────────────────────────────────────────────────────────────────────
@@ -1286,7 +1305,7 @@ class PasteDeleteView(MethodView):
"""Paste deletion with authentication."""
def delete(self, paste_id: str) -> Response:
"""Delete paste. Requires ownership."""
"""Delete paste. Requires ownership or admin rights."""
# Validate
if err := validate_paste_id(paste_id):
return err
@@ -1300,7 +1319,8 @@ class PasteDeleteView(MethodView):
if row is None:
return error_response("Paste not found", 404)
if row["owner"] != g.client_id:
# Allow if owner or admin
if row["owner"] != g.client_id and not is_admin():
return error_response("Permission denied", 403)
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
@@ -1310,15 +1330,15 @@ class PasteDeleteView(MethodView):
class PastesListView(MethodView):
"""List authenticated user's pastes (privacy-focused)."""
"""List pastes with authentication."""
def get(self) -> Response:
"""List pastes owned by authenticated user.
"""List pastes owned by authenticated user, or all pastes for admins.
Privacy guarantees:
- Requires authentication (mTLS client certificate)
- Users can ONLY see their own pastes
- No admin bypass or cross-user visibility
- Regular users can ONLY see their own pastes
- Admins can see all pastes (with optional owner filter)
- Content is never returned, only metadata
Query parameters:
@@ -1327,6 +1347,8 @@ class PastesListView(MethodView):
- type: filter by MIME type (glob pattern, e.g., "image/*")
- after: filter by created_at >= timestamp
- before: filter by created_at <= timestamp
- all: (admin only) if "1", list all pastes instead of own
- owner: (admin only) filter by owner fingerprint
"""
import fnmatch
@@ -1335,6 +1357,7 @@ class PastesListView(MethodView):
return err
client_id = g.client_id
user_is_admin = is_admin()
# Parse pagination parameters
try:
@@ -1354,11 +1377,27 @@ class PastesListView(MethodView):
except (ValueError, TypeError):
before_ts = 0
# Admin-only parameters
show_all = request.args.get("all", "0") == "1" and user_is_admin
owner_filter = request.args.get("owner", "").strip() if user_is_admin else ""
db = get_db()
# Build query with filters
where_clauses = ["owner = ?"]
params: list[Any] = [client_id]
where_clauses: list[str] = []
params: list[Any] = []
# Owner filtering logic
if show_all:
# Admin viewing all pastes (with optional owner filter)
if owner_filter:
where_clauses.append("owner = ?")
params.append(owner_filter)
# else: no owner filter, show all
else:
# Regular user or admin without ?all=1: show only own pastes
where_clauses.append("owner = ?")
params.append(client_id)
if after_ts > 0:
where_clauses.append("created_at >= ?")
@@ -1367,7 +1406,8 @@ class PastesListView(MethodView):
where_clauses.append("created_at <= ?")
params.append(before_ts)
where_sql = " AND ".join(where_clauses)
# Build WHERE clause (may be empty for admin viewing all)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# Count total pastes matching filters (where_sql is safe, built from constants)
count_row = db.execute(
@@ -1377,8 +1417,9 @@ class PastesListView(MethodView):
total = count_row["total"] if count_row else 0
# Fetch pastes with metadata only (where_sql is safe, built from constants)
# Include owner for admin view
rows = db.execute(
f"""SELECT id, mime_type, length(content) as size, created_at,
f"""SELECT id, owner, mime_type, length(content) as size, created_at,
last_accessed, burn_after_read, expires_at,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
FROM pastes
@@ -1403,6 +1444,9 @@ class PastesListView(MethodView):
"url": f"/{row['id']}",
"raw": f"/{row['id']}/raw",
}
# Include owner for admin view
if show_all and row["owner"]:
paste["owner"] = row["owner"]
if row["burn_after_read"]:
paste["burn_after_read"] = True
if row["expires_at"]:
@@ -1411,15 +1455,16 @@ class PastesListView(MethodView):
paste["password_protected"] = True
pastes.append(paste)
return json_response(
{
"pastes": pastes,
"count": len(pastes),
"total": total,
"limit": limit,
"offset": offset,
}
)
response_data: dict[str, Any] = {
"pastes": pastes,
"count": len(pastes),
"total": total,
"limit": limit,
"offset": offset,
}
if user_is_admin:
response_data["is_admin"] = True
return json_response(response_data)
# ─────────────────────────────────────────────────────────────────────────────