126 lines
3.7 KiB
Python
126 lines
3.7 KiB
Python
"""Plugin review workflow backed by PostgreSQL.
|
|
|
|
Manages the approval queue for newly submitted plugins and enforces a
|
|
security checklist before any plugin is made visible in the marketplace.
|
|
|
|
Module-level singleton::
|
|
|
|
from app.marketplace.plugin_review import review_queue
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Literal
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.marketplace.plugin_registry import registry
|
|
from app.models import PluginReview as PluginReviewModel
|
|
from app.schemas import PluginManifest
|
|
|
|
# ── Security policy ───────────────────────────────────────────────────
|
|
|
|
ALLOWED_PERMISSIONS: frozenset[str] = frozenset(
|
|
{
|
|
"read:tasks",
|
|
"write:tasks",
|
|
"read:projects",
|
|
"write:projects",
|
|
"read:notes",
|
|
"write:notes",
|
|
"read:timelines",
|
|
"write:timelines",
|
|
"read:calendar",
|
|
"write:calendar",
|
|
}
|
|
)
|
|
|
|
_PLUGIN_ID_RE = re.compile(r"^[a-z0-9-]+$")
|
|
|
|
|
|
def validate_manifest(manifest: PluginManifest) -> None:
|
|
"""Enforce the plugin security checklist.
|
|
|
|
Raises:
|
|
``ValueError`` on the first violation found. Callers should catch
|
|
this and return HTTP 422 / reject the submission.
|
|
|
|
Checks:
|
|
1. Plugin id matches ``^[a-z0-9-]+$``
|
|
2. All declared permissions are in ``ALLOWED_PERMISSIONS``
|
|
3. No manifest field contains raw binary data
|
|
"""
|
|
if not _PLUGIN_ID_RE.match(manifest.id):
|
|
raise ValueError(
|
|
f"Invalid plugin id format: '{manifest.id}'. "
|
|
"Only lowercase letters, digits, and hyphens are allowed."
|
|
)
|
|
|
|
for perm in manifest.permissions:
|
|
if perm not in ALLOWED_PERMISSIONS:
|
|
raise ValueError(
|
|
f"Unknown permission: '{perm}'. "
|
|
f"Allowed permissions: {sorted(ALLOWED_PERMISSIONS)}"
|
|
)
|
|
|
|
for field_name, value in manifest.model_dump().items():
|
|
if isinstance(value, (bytes, bytearray)):
|
|
raise ValueError(
|
|
f"Binary content is not allowed in manifest field '{field_name}'."
|
|
)
|
|
|
|
|
|
class ReviewQueue:
|
|
"""Approval queue for pending plugin submissions.
|
|
|
|
Delegates status changes to the shared ``PluginRegistry`` singleton.
|
|
Review records are persisted in the ``plugin_reviews`` table.
|
|
"""
|
|
|
|
async def get_pending(self, db: AsyncSession) -> list[dict[str, Any]]:
|
|
"""Return all plugins currently awaiting review.
|
|
|
|
Each item is ``{plugin_id, manifest, submitted_at}``.
|
|
"""
|
|
entries = await registry.get_pending_entries(db)
|
|
return [
|
|
{
|
|
"plugin_id": e["manifest"].id,
|
|
"manifest": e["manifest"],
|
|
"submitted_at": e["submitted_at"],
|
|
}
|
|
for e in entries
|
|
]
|
|
|
|
async def submit_review(
|
|
self,
|
|
db: AsyncSession,
|
|
plugin_id: str,
|
|
reviewer_id: str,
|
|
decision: Literal["approved", "rejected"],
|
|
notes: str = "",
|
|
) -> None:
|
|
"""Record a review decision and update the plugin's status.
|
|
|
|
Raises:
|
|
``KeyError`` if *plugin_id* is not found in the registry.
|
|
"""
|
|
if decision == "approved":
|
|
await registry.approve_plugin(db, plugin_id)
|
|
else:
|
|
await registry.reject_plugin(db, plugin_id, reason=notes)
|
|
|
|
review = PluginReviewModel(
|
|
plugin_id=plugin_id,
|
|
reviewer_id=reviewer_id,
|
|
decision=decision,
|
|
notes=notes,
|
|
)
|
|
db.add(review)
|
|
await db.commit()
|
|
|
|
|
|
# Module-level singleton
|
|
review_queue = ReviewQueue()
|