build(security): add migration and CI security gates
This commit is contained in:
+1
-1
@@ -9,4 +9,4 @@ COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Establish the tracked baseline schema and replace application-startup DDL.
|
||||
|
||||
Revision ID: 20260728_01
|
||||
Revises:
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401 # Register every model with Base.metadata.
|
||||
|
||||
|
||||
revision: str = "20260728_01"
|
||||
down_revision: str | None = None
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the current schema and upgrade tables created by older app versions."""
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
|
||||
# These columns were historically added in application startup code. Keep this
|
||||
# compatibility path so existing deployments can adopt Alembic safely.
|
||||
statements = (
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE",
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE",
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE daily_notes ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE work_plans ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE mini_business ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE key_visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
||||
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL",
|
||||
)
|
||||
for statement in statements:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove only objects introduced by the tracked security hardening revision.
|
||||
|
||||
The historical base tables pre-date Alembic and may contain production data, so
|
||||
they are deliberately not dropped by a downgrade operation.
|
||||
"""
|
||||
op.execute("DROP INDEX IF EXISTS uq_users_wecom_userid")
|
||||
op.execute("DROP TABLE IF EXISTS wecom_bind_tokens")
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic migration revisions for the Qiji backend."""
|
||||
@@ -1,9 +1,11 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from defusedxml import ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -38,17 +40,20 @@ class BindConfirmRequest(BaseModel):
|
||||
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
||||
items = sorted([token, timestamp, nonce, encrypted])
|
||||
raw = "".join(items)
|
||||
return hashlib.sha1(raw.encode()).hexdigest() == signature
|
||||
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
|
||||
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _decrypt_msg(encrypted: str) -> str:
|
||||
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
||||
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
||||
ciphertext = base64.b64decode(encrypted)
|
||||
from Crypto.Cipher import AES
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
|
||||
plaintext = cipher.decrypt(ciphertext)
|
||||
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
||||
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
pad_len = plaintext[-1]
|
||||
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
|
||||
raise ValueError("Invalid callback padding")
|
||||
plaintext = plaintext[:-pad_len]
|
||||
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
||||
return plaintext[20:20 + msg_len].decode("utf-8")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
# App
|
||||
APP_NAME: str = "企迹-政企周报管理系统"
|
||||
ENVIRONMENT: str = "production"
|
||||
@@ -49,11 +50,6 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
|
||||
+1
-66
@@ -3,7 +3,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.database import engine, Base, async_session
|
||||
from app.database import engine, async_session
|
||||
from app.api import router as api_router
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
||||
@@ -15,71 +15,6 @@ from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
validate_security_settings()
|
||||
# Startup: create tables if not exists (for dev convenience)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns that may be missing from older tables
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
||||
))
|
||||
# edit_log columns for change tracking
|
||||
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
||||
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
||||
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
||||
))
|
||||
# New columns for v0.3
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
||||
))
|
||||
# New columns for v0.4
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
||||
))
|
||||
# v0.6 — manager color tag
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
||||
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL"
|
||||
))
|
||||
# system_config table for v0.5
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')"
|
||||
))
|
||||
# leaves table for v0.7
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE TABLE IF NOT EXISTS leaves ("
|
||||
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
|
||||
" manager_id UUID NOT NULL REFERENCES users(id),"
|
||||
" leave_type VARCHAR(20) NOT NULL DEFAULT '事假',"
|
||||
" start_date DATE NOT NULL,"
|
||||
" end_date DATE NOT NULL,"
|
||||
" reason VARCHAR(500) DEFAULT '',"
|
||||
" submitted_by UUID NOT NULL REFERENCES users(id),"
|
||||
" created_at TIMESTAMPTZ DEFAULT now(),"
|
||||
" updated_at TIMESTAMPTZ DEFAULT now()"
|
||||
")"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
|
||||
))
|
||||
|
||||
# Read notification_time from DB (or use default 17:30)
|
||||
notification_hour, notification_minute = 17, 30
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -91,14 +92,14 @@ class WecomClient:
|
||||
|
||||
if errcode == 45009:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(1 * (attempt + 1))
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
continue
|
||||
|
||||
last_error = data
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
last_error = {"errcode": -1, "errmsg": str(e)}
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import jwt, JWTError
|
||||
import jwt
|
||||
from jwt import InvalidTokenError
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Fail CI for parse errors and undefined names. Broader style cleanup is tracked
|
||||
# separately because the existing codebase has a substantial legacy backlog.
|
||||
select = ["E9", "F63", "F7", "F82"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# SQLAlchemy resolves these relationship targets from strings after model import.
|
||||
"app/models/*.py" = ["F821"]
|
||||
@@ -1,16 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
fastapi==0.140.7
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
PyJWT==2.13.0
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.18
|
||||
python-multipart==0.0.32
|
||||
minio==7.2.10
|
||||
openpyxl==3.1.5
|
||||
apscheduler==3.11.0
|
||||
python-dotenv==1.0.1
|
||||
pycryptodome
|
||||
Pillow==11.1.0
|
||||
python-dotenv==1.2.2
|
||||
cryptography==49.0.0
|
||||
defusedxml==0.7.1
|
||||
Pillow==12.3.0
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from app.api import system_config, upload
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (8, 8), color=(10, 20, 30)).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _upload_client(authenticated: bool) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(upload.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[upload.require_any_role] = lambda: {
|
||||
"user_id": "a2d4447f-7b67-4304-ad9a-953007e75ef2",
|
||||
"role": "manager",
|
||||
}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_upload_endpoint_requires_bearer_authentication():
|
||||
response = _upload_client(authenticated=False).post(
|
||||
"/upload/image",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_upload_endpoint_validates_and_normalizes_before_storage():
|
||||
with patch("app.api.upload.put_validated_image") as put_image:
|
||||
response = _upload_client(authenticated=True).post(
|
||||
"/upload/image",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
object_key = response.json()["object_key"]
|
||||
assert object_key.startswith("uploads/a2d4447f-7b67-4304-ad9a-953007e75ef2/")
|
||||
assert object_key.endswith(".jpg")
|
||||
stored_bytes = put_image.call_args.args[1]
|
||||
with Image.open(BytesIO(stored_bytes)) as image:
|
||||
assert image.format == "JPEG"
|
||||
|
||||
|
||||
def test_system_configuration_cannot_be_read_without_director_authentication():
|
||||
app = FastAPI()
|
||||
app.include_router(system_config.router)
|
||||
|
||||
response = TestClient(app).get("/system-config")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.config import settings
|
||||
from app.utils.security import create_access_token, decode_token
|
||||
|
||||
|
||||
def test_jwt_round_trip_and_tamper_rejection(monkeypatch):
|
||||
monkeypatch.setattr(settings, "SECRET_KEY", "x" * 32)
|
||||
token = create_access_token({"user_id": "user-1", "role": "manager"})
|
||||
|
||||
assert decode_token(token)["user_id"] == "user-1"
|
||||
assert decode_token(f"{token}tampered") is None
|
||||
Reference in New Issue
Block a user