phase3: plugin routes static only (no dynamic registration) + require_active_plugin Redis cache + cache invalidation on activate/deactivate
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-07-29 17:40:40 +02:00
parent 840795b5b9
commit 481125e29e
2 changed files with 97 additions and 42 deletions
+74 -19
View File
@@ -293,7 +293,12 @@ def require_active_plugin(plugin_name: str):
Checks both global activation (permission registry) and per-tenant
activation (tenant_plugin_activation table).
Uses Redis cache for per-tenant check to avoid DB query on every request.
Cache key: plugin-activation:{tenant_id}:{plugin_name}
TTL: 60 seconds. Invalidated on activate/deactivate.
Returns 403 if the plugin is not active.
Fails closed (503) on errors.
"""
async def _check() -> None:
from app.core.permission_registry import get_permission_registry
@@ -307,31 +312,81 @@ def require_active_plugin(plugin_name: str):
"code": "plugin_inactive",
},
)
# Per-tenant activation check (P1.4 fix)
# If there's an entry in tenant_plugin_activation for this
# tenant+plugin with is_active=false, deny access.
# If no entry exists, default to active (backward compatible).
# Per-tenant activation check with Redis cache
from app.core.redis import get_redis
from app.core.db import async_session_maker
from sqlalchemy import text
import json
redis = get_redis()
# Get tenant_id from current session context
async with async_session_maker() as db:
# Get tenant_id from current session context (RLS)
result = await db.execute(
text("""
SELECT is_active FROM tenant_plugin_activation
WHERE plugin_name = :name
AND tenant_id = current_setting('app.current_tenant_id', true)::uuid
"""),
{"name": plugin_name},
text("SELECT current_setting('app.current_tenant_id', true)::uuid")
)
row = result.first()
if row is not None and not row[0]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Plugin '{plugin_name}' is not active for this tenant",
"code": "plugin_inactive_tenant",
},
tenant_id = result.scalar()
if tenant_id is not None and redis is not None:
cache_key = f"plugin-activation:{tenant_id}:{plugin_name}"
cached = await redis.get(cache_key)
if cached is not None:
is_active = json.loads(cached)
if not is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Plugin '{plugin_name}' is not active for this tenant",
"code": "plugin_inactive_tenant",
},
)
return # Cache hit — plugin is active for this tenant
# Cache miss — query DB
async with async_session_maker() as db:
result = await db.execute(
text("""
SELECT is_active FROM tenant_plugin_activation
WHERE plugin_name = :name
AND tenant_id = :tid
"""),
{"name": plugin_name, "tid": tenant_id},
)
row = result.first()
if row is not None:
is_active = row[0]
# Cache the result (60s TTL)
await redis.setex(cache_key, 60, json.dumps(is_active))
if not is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Plugin '{plugin_name}' is not active for this tenant",
"code": "plugin_inactive_tenant",
},
)
else:
# No entry = default active (backward compatible)
await redis.setex(cache_key, 60, json.dumps(True))
else:
# No Redis or no tenant_id — fallback to DB query without cache
async with async_session_maker() as db:
result = await db.execute(
text("""
SELECT is_active FROM tenant_plugin_activation
WHERE plugin_name = :name
AND tenant_id = current_setting('app.current_tenant_id', true)::uuid
"""),
{"name": plugin_name},
)
row = result.first()
if row is not None and not row[0]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Plugin '{plugin_name}' is not active for this tenant",
"code": "plugin_inactive_tenant",
},
)
except HTTPException:
raise
except Exception as exc: