- fix(config): 로컬 실행 시 루트의 .env 파일을 자동 로드하도록 Settings.from_env 개선 (테스트 실행 시 격리) - fix(ops): 기존 8000번 포트 충돌 방지를 위해 docker-compose.ops.yml 호스트 포트를 127.0.0.1:18000:8000으로 조정 - fix(git-binding): Git 저장소 바인딩 시 projects 테이블의 branch도 함께 동기화하도록 app.py 보완 - ui(projects): 프로젝트 카드의 BRANCH 표시에 바인딩된 브랜치를 우선 표시하고, OWNER 라벨을 '담당'으로 명확화 - feat(deploy): Ubuntu Apache2 웹서버용 Gitea 및 SourceHub 역방향 프록시 설정 템플릿 추가 (deploy/apache-*.conf) - docs: 프로젝트 CRUD 및 사내 개발자 관리 기능 갭 분석 및 개발 요청서 Word 문서 추가 (docs/)
578 lines
30 KiB
Python
578 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, insert, select, update
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
|
|
from sourcehub.auth import AuthManager
|
|
from sourcehub.config import Settings
|
|
from sourcehub.db import (
|
|
ROLES, approval_steps, audit_logs, baseline_packages, build_engine, comments,
|
|
deployment_runs, deployments, init_db, projects, repo_bindings, revisions,
|
|
role_bindings, transfers,
|
|
)
|
|
from sourcehub.deploy import DeploymentExecutor
|
|
from sourcehub.git import GiteaConnector
|
|
from sourcehub.scanner import build_scanner
|
|
from sourcehub.security import DownloadSigner, check_csrf, csrf_token
|
|
from sourcehub.storage import build_storage
|
|
from sourcehub.upload_pipeline import UploadPipeline
|
|
from sourcehub.util import json_text, now_iso, safe_segment, sanitize_filename
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
settings = Settings.from_env(BASE_DIR)
|
|
engine = build_engine(settings)
|
|
storage = auth = gitea = scanner = upload_pipeline = deployer = signer = None
|
|
|
|
APPROVAL_STEPS = [
|
|
("RECEIVED", "수신 확인"),
|
|
("QA_REVIEW", "통합·QA"),
|
|
("CONTENT_REVIEW", "콘텐츠 확인"),
|
|
("DEPLOY_REVIEW", "배포 승인"),
|
|
]
|
|
|
|
LOGIN_PAGE = """<!doctype html><html lang='ko'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>NEXPA SourceHub Login</title><style>body{margin:0;background:#071f46;color:#fff;font-family:'Malgun Gothic',sans-serif;min-height:100vh;display:grid;place-items:center}.box{width:min(520px,calc(100% - 32px));background:#fff;color:#172338;border-radius:18px;padding:42px;box-shadow:0 30px 80px rgba(0,0,0,.28)}.brand{font-size:30px;font-weight:900;letter-spacing:.08em}.line{width:44px;height:3px;background:#c9a85d;margin:14px 0 22px}.k{font-size:10px;letter-spacing:.16em;color:#1f5fbf;font-weight:900}.box h1{font-size:28px;margin:10px 0}.box p{color:#718096;line-height:1.7;font-size:13px}.btn{display:block;text-align:center;text-decoration:none;background:#071f46;color:#fff;padding:16px;border-radius:10px;font-weight:900;margin-top:24px}</style></head><body><div class='box'><div class='brand'>NEXPA</div><div class='line'></div><div class='k'>SOURCEHUB · INTERNAL SYSTEM</div><h1>NEXPA Core 로그인</h1><p>개발 소스, 승인, 배포 이력은 인증된 내부 사용자에게만 표시됩니다.</p><a class='btn' href='/auth/login'>로그인</a></div></body></html>"""
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global storage, auth, gitea, scanner, upload_pipeline, deployer, signer
|
|
init_db(engine, now_iso())
|
|
last_storage_error = None
|
|
for attempt in range(15):
|
|
try:
|
|
storage = build_storage(settings)
|
|
last_storage_error = None
|
|
break
|
|
except Exception as exc:
|
|
last_storage_error = exc
|
|
if attempt == 14:
|
|
raise
|
|
await asyncio.sleep(2)
|
|
if last_storage_error:
|
|
raise last_storage_error
|
|
auth = AuthManager(settings, engine)
|
|
gitea = GiteaConnector(settings)
|
|
scanner = build_scanner(settings.malware_scan_mode, settings.clamd_host, settings.clamd_port)
|
|
upload_pipeline = UploadPipeline(settings.max_upload_mb, scanner)
|
|
deployer = DeploymentExecutor(settings.deploy_mode, settings.deploy_targets_json, settings.deploy_webhook_secret)
|
|
signer = DownloadSigner(settings.download_secret, settings.download_ttl_seconds)
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="NEXPA SourceHub", version="3.8.0", lifespan=lifespan)
|
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=settings.session_secret,
|
|
https_only=settings.session_https_only,
|
|
same_site="lax",
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def security_headers(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "no-referrer"
|
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
response.headers["Content-Security-Policy"] = (
|
|
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
|
)
|
|
if settings.enable_hsts:
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
return response
|
|
|
|
|
|
def actor(request: Request, x_role: Optional[str]):
|
|
return auth.actor(request, x_role)
|
|
|
|
|
|
def mutation_actor(request: Request, x_role: Optional[str], x_csrf_token: Optional[str]):
|
|
a = actor(request, x_role)
|
|
check_csrf(request, x_csrf_token, settings.csrf_enabled)
|
|
return a
|
|
|
|
|
|
def role_name(role):
|
|
return ROLES.get(role, (role, ""))[0]
|
|
|
|
|
|
def log(conn, role, kind, action, target, meta=None):
|
|
conn.execute(insert(audit_logs).values(
|
|
at=now_iso(), actor_role=role, action_type=kind, action=action,
|
|
target=target, meta_json=json_text(meta or {}),
|
|
))
|
|
|
|
|
|
def transfer_payload(conn, row):
|
|
p = conn.execute(select(projects).where(projects.c.code == row["project_code"])).mappings().first()
|
|
rs = conn.execute(select(revisions).where(revisions.c.transfer_id == row["id"]).order_by(revisions.c.revision_no.desc())).mappings().all()
|
|
ss = conn.execute(select(approval_steps).where(approval_steps.c.transfer_id == row["id"]).order_by(approval_steps.c.step_order)).mappings().all()
|
|
cs = conn.execute(select(comments).where(comments.c.transfer_id == row["id"]).order_by(comments.c.id.desc())).mappings().all()
|
|
return {
|
|
"id": row["id"], "project_code": row["project_code"], "project": p["name"],
|
|
"version": row["version"], "sender_role": row["sender_role"], "sender": role_name(row["sender_role"]),
|
|
"receiver_role": row["receiver_role"], "receiver": role_name(row["receiver_role"]),
|
|
"file_type": row["file_type"], "git_ref": row["git_ref"], "branch": row["branch"],
|
|
"summary": row["summary"], "state": row["state"], "current_step": row["current_step"],
|
|
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
|
"revisions": [{
|
|
"id": r["id"], "revision_no": r["revision_no"], "filename": r["original_filename"],
|
|
"sha256": r["sha256"], "file_size": r["file_size"], "created_by": role_name(r["created_by"]),
|
|
"created_at": r["created_at"], "download_link_api": f"/api/revisions/{r['id']}/download-link",
|
|
} for r in rs],
|
|
"steps": [{
|
|
"order": s["step_order"], "code": s["step_code"], "label": s["label"],
|
|
"assignee_role": s["assignee_role"], "assignee": role_name(s["assignee_role"]),
|
|
"state": s["state"], "decided_by": role_name(s["decided_by"]) if s["decided_by"] else None,
|
|
"decided_at": s["decided_at"], "note": s["note"],
|
|
} for s in ss],
|
|
"comments": [{
|
|
"id": c["id"], "author_role": c["author_role"], "author": role_name(c["author_role"]),
|
|
"body": c["body"], "created_at": c["created_at"],
|
|
} for c in cs],
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
db_ok = True
|
|
try:
|
|
with engine.connect() as conn:
|
|
conn.execute(select(func.count()).select_from(projects)).scalar_one()
|
|
except Exception:
|
|
db_ok = False
|
|
return {"ok": db_ok, "service": "NEXPA SourceHub", "version": "3.8.0"}
|
|
|
|
|
|
@app.get("/")
|
|
def root(request: Request):
|
|
if settings.auth_mode == "oidc" and not request.session.get("sourcehub_user"):
|
|
return HTMLResponse(LOGIN_PAGE)
|
|
return FileResponse(BASE_DIR / "static" / "index.html")
|
|
|
|
|
|
@app.get("/api/system/status")
|
|
def system_status(request: Request):
|
|
a = auth.status(request)
|
|
session_secret_ok = len(settings.session_secret) >= 32 and "CHANGE" not in settings.session_secret.upper()
|
|
download_secret_ok = len(settings.download_secret) >= 32 and "CHANGE" not in settings.download_secret.upper()
|
|
readiness = {
|
|
"postgresql": settings.database_kind == "postgresql",
|
|
"object_storage": storage.name == "minio",
|
|
"oidc_gate": settings.auth_mode == "oidc" and settings.oidc_configured,
|
|
"malware_scan": scanner.configured,
|
|
"secure_cookie": settings.session_https_only,
|
|
"csrf": settings.csrf_enabled,
|
|
"hsts": settings.enable_hsts,
|
|
"session_secret": session_secret_ok,
|
|
"download_secret": download_secret_ok,
|
|
"deploy_executor": deployer.configured,
|
|
}
|
|
return {
|
|
"version": "3.8.0",
|
|
"database": {"kind": settings.database_kind, "connected": True},
|
|
"storage": {"backend": storage.name, "connected": storage.configured},
|
|
"auth": a,
|
|
"git": {"provider": "gitea", "configured": gitea.configured},
|
|
"security": {
|
|
"csrf": settings.csrf_enabled,
|
|
"signed_download": True,
|
|
"download_ttl_seconds": settings.download_ttl_seconds,
|
|
"malware_scan": scanner.name,
|
|
"malware_scan_configured": scanner.configured,
|
|
"secure_cookie": settings.session_https_only,
|
|
"hsts": settings.enable_hsts,
|
|
},
|
|
"deploy": {"mode": deployer.mode, "configured": deployer.configured},
|
|
"readiness": readiness,
|
|
"production_ready": all(readiness.values()),
|
|
}
|
|
|
|
|
|
@app.get("/api/security/csrf")
|
|
def get_csrf(request: Request, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
return {"enabled": settings.csrf_enabled, "token": csrf_token(request) if settings.csrf_enabled else ""}
|
|
|
|
|
|
@app.get("/api/auth/status")
|
|
def auth_status(request: Request):
|
|
return auth.status(request)
|
|
|
|
|
|
@app.get("/auth/login")
|
|
async def auth_login(request: Request):
|
|
if settings.auth_mode != "oidc":
|
|
return RedirectResponse("/")
|
|
if not auth.oidc_ready:
|
|
raise HTTPException(503, "OIDC 환경설정 또는 Authlib 설치가 필요합니다.")
|
|
redirect_uri = str(request.url_for("auth_callback"))
|
|
return await auth.oauth.nexpa.authorize_redirect(request, redirect_uri)
|
|
|
|
|
|
@app.get("/auth/callback", name="auth_callback")
|
|
async def auth_callback(request: Request):
|
|
if not auth.oidc_ready:
|
|
raise HTTPException(503, "OIDC 설정이 준비되지 않았습니다.")
|
|
token = await auth.oauth.nexpa.authorize_access_token(request)
|
|
userinfo = token.get("userinfo") or await auth.oauth.nexpa.userinfo(token=token)
|
|
claims = dict(userinfo)
|
|
role = auth.resolve_role(claims)
|
|
request.session["sourcehub_user"] = {
|
|
"sub": claims.get("sub"), "email": claims.get("email"),
|
|
"name": claims.get("name") or claims.get("preferred_username"), "role": role,
|
|
}
|
|
csrf_token(request)
|
|
return RedirectResponse("/")
|
|
|
|
|
|
@app.post("/auth/logout")
|
|
def auth_logout(request: Request, x_csrf_token: Optional[str] = Header(default=None)):
|
|
check_csrf(request, x_csrf_token, settings.csrf_enabled)
|
|
request.session.clear()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/me")
|
|
def me(request: Request, x_role: Optional[str] = Header(default=None)):
|
|
a = actor(request, x_role)
|
|
return {"role": a.role, "name": a.name, "description": a.description, "sub": a.sub, "email": a.email}
|
|
|
|
|
|
@app.get("/api/projects")
|
|
def get_projects(request: Request, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(select(projects).order_by(projects.c.code)).mappings().all()
|
|
bindings = {r["project_code"]: r for r in conn.execute(select(repo_bindings)).mappings().all()}
|
|
return [{**dict(r), "owner": role_name(r["owner_role"]), "repo_binding": dict(bindings.get(r["code"])) if bindings.get(r["code"]) else None} for r in rows]
|
|
|
|
|
|
@app.get("/api/baselines")
|
|
def get_baselines(request: Request, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
with engine.begin() as conn:
|
|
return [dict(r) for r in conn.execute(select(baseline_packages).order_by(baseline_packages.c.id)).mappings().all()]
|
|
|
|
|
|
@app.get("/api/transfers")
|
|
def get_transfers(request: Request, project: Optional[str] = None, state: Optional[str] = None, q: Optional[str] = None, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
stmt = select(transfers).order_by(transfers.c.created_at.desc())
|
|
if project:
|
|
stmt = stmt.where(transfers.c.project_code == project)
|
|
if state:
|
|
stmt = stmt.where(transfers.c.state == state)
|
|
if q:
|
|
like = f"%{q.lower()}%"
|
|
stmt = stmt.where(func.lower(transfers.c.summary).like(like) | func.lower(transfers.c.version).like(like))
|
|
with engine.begin() as conn:
|
|
return [transfer_payload(conn, r) for r in conn.execute(stmt).mappings().all()]
|
|
|
|
|
|
@app.get("/api/transfers/{transfer_id}")
|
|
def get_transfer(transfer_id: str, request: Request, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
with engine.begin() as conn:
|
|
row = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
if not row:
|
|
raise HTTPException(404, "전달 기록을 찾을 수 없습니다.")
|
|
return transfer_payload(conn, row)
|
|
|
|
|
|
@app.post("/api/transfers")
|
|
def create_transfer(
|
|
request: Request,
|
|
project_code: str = Form(...), version: str = Form(...), receiver_role: str = Form(...),
|
|
file_type: str = Form(...), summary: str = Form(...), git_ref: str = Form(""), branch: str = Form(""),
|
|
file: UploadFile = File(...), x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None),
|
|
):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
if receiver_role not in ROLES:
|
|
raise HTTPException(400, "받는 역할이 올바르지 않습니다.")
|
|
tid, rid, created = uuid.uuid4().hex[:16], uuid.uuid4().hex[:16], now_iso()
|
|
filename = sanitize_filename(file.filename or "upload.bin")
|
|
prepared = upload_pipeline.prepare(file)
|
|
try:
|
|
with engine.begin() as conn:
|
|
p = conn.execute(select(projects).where(projects.c.code == project_code)).mappings().first()
|
|
if not p:
|
|
raise HTTPException(400, "프로젝트가 존재하지 않습니다.")
|
|
conn.execute(insert(transfers).values(
|
|
id=tid, project_code=project_code, version=version, sender_role=a.role, receiver_role=receiver_role,
|
|
file_type=file_type, git_ref=git_ref or None, branch=branch or None, summary=summary,
|
|
state="UPLOADED", current_step=0, created_at=created, updated_at=created,
|
|
))
|
|
step_roles = [receiver_role, "dev3", "planner", "deploy"]
|
|
for idx, ((code, label), assignee) in enumerate(zip(APPROVAL_STEPS, step_roles)):
|
|
conn.execute(insert(approval_steps).values(
|
|
transfer_id=tid, step_order=idx, step_code=code, label=label, assignee_role=assignee,
|
|
state="ACTIVE" if idx == 0 else "PENDING",
|
|
))
|
|
object_key = f"{safe_segment(project_code)}/{safe_segment(version)}/{tid}/r1/{rid}_{filename}"
|
|
stored = storage.put_file(prepared.temp_path, object_key)
|
|
conn.execute(insert(revisions).values(
|
|
id=rid, transfer_id=tid, revision_no=1, original_filename=filename, object_key=stored.object_key,
|
|
sha256=prepared.sha256, file_size=prepared.file_size, created_by=a.role, created_at=created,
|
|
))
|
|
log(conn, a.role, "전달", f"{p['name']} {version} R1 등록", f"{p['name']} {version}", {
|
|
"sha256": prepared.sha256, "scan_engine": prepared.scan_engine, "scan_detail": prepared.scan_detail,
|
|
})
|
|
row = conn.execute(select(transfers).where(transfers.c.id == tid)).mappings().first()
|
|
return transfer_payload(conn, row)
|
|
finally:
|
|
prepared.temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
@app.post("/api/transfers/{transfer_id}/revisions")
|
|
def add_revision(
|
|
transfer_id: str, request: Request, summary: str = Form(""), file: UploadFile = File(...),
|
|
x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None),
|
|
):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
created, rid = now_iso(), uuid.uuid4().hex[:16]
|
|
filename = sanitize_filename(file.filename or "upload.bin")
|
|
prepared = upload_pipeline.prepare(file)
|
|
try:
|
|
with engine.begin() as conn:
|
|
t = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
if not t:
|
|
raise HTTPException(404, "전달 기록을 찾을 수 없습니다.")
|
|
max_rev = conn.execute(select(func.max(revisions.c.revision_no)).where(revisions.c.transfer_id == transfer_id)).scalar() or 0
|
|
n = max_rev + 1
|
|
key = f"{safe_segment(t['project_code'])}/{safe_segment(t['version'])}/{transfer_id}/r{n}/{rid}_{filename}"
|
|
stored = storage.put_file(prepared.temp_path, key)
|
|
conn.execute(insert(revisions).values(
|
|
id=rid, transfer_id=transfer_id, revision_no=n, original_filename=filename, object_key=stored.object_key,
|
|
sha256=prepared.sha256, file_size=prepared.file_size, created_by=a.role, created_at=created,
|
|
))
|
|
conn.execute(update(transfers).where(transfers.c.id == transfer_id).values(state="UPLOADED", current_step=0, updated_at=created))
|
|
conn.execute(update(approval_steps).where(approval_steps.c.transfer_id == transfer_id).values(state="PENDING", decided_by=None, decided_at=None, note=None))
|
|
first = conn.execute(select(approval_steps.c.id).where(approval_steps.c.transfer_id == transfer_id, approval_steps.c.step_order == 0)).first()
|
|
conn.execute(update(approval_steps).where(approval_steps.c.id == first[0]).values(state="ACTIVE"))
|
|
p = conn.execute(select(projects.c.name).where(projects.c.code == t["project_code"])).scalar_one()
|
|
log(conn, a.role, "전달", f"{p} {t['version']} R{n} 추가", f"{p} {t['version']}", {
|
|
"sha256": prepared.sha256, "summary": summary, "scan_engine": prepared.scan_engine,
|
|
})
|
|
row = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
return transfer_payload(conn, row)
|
|
finally:
|
|
prepared.temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
@app.get("/api/revisions/{revision_id}/download-link")
|
|
def revision_download_link(revision_id: str, request: Request, x_role: Optional[str] = Header(default=None)):
|
|
a = actor(request, x_role)
|
|
with engine.begin() as conn:
|
|
r = conn.execute(select(revisions.c.id).where(revisions.c.id == revision_id)).first()
|
|
if not r:
|
|
raise HTTPException(404, "Revision 파일을 찾을 수 없습니다.")
|
|
subject = a.sub or a.email or a.role
|
|
token = signer.issue(revision_id, subject)
|
|
return {"url": f"/download/{revision_id}?token={token}", "expires_in": settings.download_ttl_seconds}
|
|
|
|
|
|
@app.get("/download/{revision_id}")
|
|
def signed_download(revision_id: str, token: str):
|
|
signer.verify(token, revision_id)
|
|
with engine.begin() as conn:
|
|
r = conn.execute(select(revisions).where(revisions.c.id == revision_id)).mappings().first()
|
|
if not r:
|
|
raise HTTPException(404, "Revision 파일을 찾을 수 없습니다.")
|
|
return storage.download_response(r["object_key"], r["original_filename"])
|
|
|
|
|
|
class Decision(BaseModel):
|
|
note: str = ""
|
|
|
|
|
|
@app.post("/api/transfers/{transfer_id}/approve")
|
|
def approve_transfer(transfer_id: str, body: Decision, request: Request, x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None)):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
with engine.begin() as conn:
|
|
t = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
if not t:
|
|
raise HTTPException(404, "전달 기록을 찾을 수 없습니다.")
|
|
step = conn.execute(select(approval_steps).where(approval_steps.c.transfer_id == transfer_id, approval_steps.c.state == "ACTIVE").order_by(approval_steps.c.step_order)).mappings().first()
|
|
if not step:
|
|
if t["state"] == "APPROVED":
|
|
return transfer_payload(conn, t)
|
|
raise HTTPException(409, "현재 승인 가능한 단계가 없습니다.")
|
|
if step["assignee_role"] != a.role:
|
|
raise HTTPException(403, f"현재 단계 담당자는 {role_name(step['assignee_role'])}입니다.")
|
|
decided = now_iso()
|
|
conn.execute(update(approval_steps).where(approval_steps.c.id == step["id"]).values(state="DONE", decided_by=a.role, decided_at=decided, note=body.note))
|
|
nxt = conn.execute(select(approval_steps).where(approval_steps.c.transfer_id == transfer_id, approval_steps.c.step_order == step["step_order"] + 1)).mappings().first()
|
|
if nxt:
|
|
conn.execute(update(approval_steps).where(approval_steps.c.id == nxt["id"]).values(state="ACTIVE"))
|
|
new_state, next_idx = nxt["step_code"], step["step_order"] + 1
|
|
else:
|
|
new_state, next_idx = "APPROVED", len(APPROVAL_STEPS)
|
|
conn.execute(update(transfers).where(transfers.c.id == transfer_id).values(state=new_state, current_step=next_idx, updated_at=decided))
|
|
p = conn.execute(select(projects.c.name).where(projects.c.code == t["project_code"])).scalar_one()
|
|
log(conn, a.role, "검수", f"{step['label']} 승인", f"{p} {t['version']}", {"note": body.note})
|
|
row = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
return transfer_payload(conn, row)
|
|
|
|
|
|
@app.post("/api/transfers/{transfer_id}/request-changes")
|
|
def request_changes(transfer_id: str, body: Decision, request: Request, x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None)):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
with engine.begin() as conn:
|
|
t = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
if not t:
|
|
raise HTTPException(404, "전달 기록을 찾을 수 없습니다.")
|
|
step = conn.execute(select(approval_steps).where(approval_steps.c.transfer_id == transfer_id, approval_steps.c.state == "ACTIVE").order_by(approval_steps.c.step_order)).mappings().first()
|
|
if step and step["assignee_role"] != a.role:
|
|
raise HTTPException(403, f"현재 단계 담당자는 {role_name(step['assignee_role'])}입니다.")
|
|
conn.execute(update(transfers).where(transfers.c.id == transfer_id).values(state="CHANGES_REQUESTED", updated_at=now_iso()))
|
|
if step:
|
|
conn.execute(update(approval_steps).where(approval_steps.c.id == step["id"]).values(state="CHANGES_REQUESTED", decided_by=a.role, decided_at=now_iso(), note=body.note))
|
|
p = conn.execute(select(projects.c.name).where(projects.c.code == t["project_code"])).scalar_one()
|
|
log(conn, a.role, "검수", "수정 요청", f"{p} {t['version']}", {"note": body.note})
|
|
row = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
return transfer_payload(conn, row)
|
|
|
|
|
|
class CommentIn(BaseModel):
|
|
body: str
|
|
|
|
|
|
@app.post("/api/transfers/{transfer_id}/comments")
|
|
def add_comment(transfer_id: str, body: CommentIn, request: Request, x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None)):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
text = body.body.strip()
|
|
if not text:
|
|
raise HTTPException(400, "댓글 내용을 입력하세요.")
|
|
with engine.begin() as conn:
|
|
t = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
if not t:
|
|
raise HTTPException(404, "전달 기록을 찾을 수 없습니다.")
|
|
conn.execute(insert(comments).values(transfer_id=transfer_id, author_role=a.role, body=text[:3000], created_at=now_iso()))
|
|
p = conn.execute(select(projects.c.name).where(projects.c.code == t["project_code"])).scalar_one()
|
|
log(conn, a.role, "댓글", "협업 메모 등록", f"{p} {t['version']}")
|
|
row = conn.execute(select(transfers).where(transfers.c.id == transfer_id)).mappings().first()
|
|
return transfer_payload(conn, row)
|
|
|
|
|
|
class RepoBindIn(BaseModel):
|
|
owner: str
|
|
repo: str
|
|
branch: str
|
|
|
|
|
|
@app.get("/api/projects/{project_code}/git/status")
|
|
def git_status(project_code: str, request: Request, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
with engine.begin() as conn:
|
|
b = conn.execute(select(repo_bindings).where(repo_bindings.c.project_code == project_code)).mappings().first()
|
|
if not b:
|
|
raise HTTPException(404, "프로젝트 Git 바인딩을 찾을 수 없습니다.")
|
|
return {"binding": dict(b), "status": gitea.branch_status(b["owner"], b["repo"], b["branch"])}
|
|
|
|
|
|
@app.post("/api/projects/{project_code}/git/bind")
|
|
def bind_git(project_code: str, body: RepoBindIn, request: Request, x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None)):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
if a.role not in {"dev1", "deploy"}:
|
|
raise HTTPException(403, "Git 저장소 바인딩은 개발자 1 또는 배포 담당자만 변경할 수 있습니다.")
|
|
with engine.begin() as conn:
|
|
p = conn.execute(select(projects).where(projects.c.code == project_code)).mappings().first()
|
|
if not p:
|
|
raise HTTPException(404, "프로젝트를 찾을 수 없습니다.")
|
|
conn.execute(update(repo_bindings).where(repo_bindings.c.project_code == project_code).values(
|
|
owner=body.owner.strip(), repo=body.repo.strip(), branch=body.branch.strip(), updated_at=now_iso(), updated_by=a.role,
|
|
))
|
|
conn.execute(update(projects).where(projects.c.code == project_code).values(
|
|
branch=body.branch.strip(),
|
|
))
|
|
log(conn, a.role, "Git", "저장소 바인딩 변경", p["name"], {"owner": body.owner, "repo": body.repo, "branch": body.branch})
|
|
return git_status(project_code, request, x_role)
|
|
|
|
|
|
class RoleBindIn(BaseModel):
|
|
oidc_sub: Optional[str] = None
|
|
email: Optional[str] = None
|
|
sourcehub_role: str
|
|
|
|
|
|
@app.post("/api/admin/role-bindings")
|
|
def bind_role(body: RoleBindIn, request: Request, x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None)):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
if a.role not in {"dev1", "deploy"}:
|
|
raise HTTPException(403, "Role Binding은 개발자 1 또는 배포 담당자만 변경할 수 있습니다.")
|
|
if body.sourcehub_role not in ROLES:
|
|
raise HTTPException(400, "SourceHub 역할이 올바르지 않습니다.")
|
|
if not body.oidc_sub and not body.email:
|
|
raise HTTPException(400, "OIDC sub 또는 email이 필요합니다.")
|
|
with engine.begin() as conn:
|
|
conn.execute(insert(role_bindings).values(oidc_sub=body.oidc_sub or None, email=body.email.lower() if body.email else None, sourcehub_role=body.sourcehub_role, active=True))
|
|
log(conn, a.role, "권한", "OIDC Role Binding 추가", body.email or body.oidc_sub, {"role": body.sourcehub_role})
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/deployments")
|
|
def get_deployments(request: Request, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(select(deployments, projects.c.name.label("project_name")).join(projects, deployments.c.project_code == projects.c.code).order_by(deployments.c.id)).mappings().all()
|
|
return [{
|
|
"id": r["id"], "project_code": r["project_code"], "project": r["project_name"],
|
|
"version": r["version"], "environment": r["environment"], "status": r["status"],
|
|
"checks": json.loads(r["checks_json"]), "updated_at": r["updated_at"],
|
|
} for r in rows]
|
|
|
|
|
|
@app.post("/api/deployments/{deployment_id}/execute")
|
|
def execute_deployment(deployment_id: int, request: Request, x_role: Optional[str] = Header(default=None), x_csrf_token: Optional[str] = Header(default=None)):
|
|
a = mutation_actor(request, x_role, x_csrf_token)
|
|
if a.role != "deploy":
|
|
raise HTTPException(403, "실제 배포 실행은 배포 담당자만 가능합니다.")
|
|
with engine.begin() as conn:
|
|
d = conn.execute(select(deployments).where(deployments.c.id == deployment_id)).mappings().first()
|
|
if not d:
|
|
raise HTTPException(404, "배포 항목을 찾을 수 없습니다.")
|
|
checks = json.loads(d["checks_json"])
|
|
missing = [name for name, ok in checks if not ok]
|
|
if missing:
|
|
raise HTTPException(409, f"미완료 배포 Gate: {', '.join(missing)}")
|
|
run_id = uuid.uuid4().hex[:16]
|
|
payload = {"run_id": run_id, "project_code": d["project_code"], "version": d["version"], "environment": d["environment"], "requested_by": a.role, "requested_at": now_iso()}
|
|
result = deployer.execute(d["project_code"], d["environment"], payload)
|
|
conn.execute(insert(deployment_runs).values(
|
|
id=run_id, deployment_id=d["id"], project_code=d["project_code"], version=d["version"], environment=d["environment"],
|
|
requested_by=a.role, status="ACCEPTED" if result.accepted else "REJECTED", executor_mode=result.mode,
|
|
executor_detail=result.detail, created_at=now_iso(),
|
|
))
|
|
log(conn, a.role, "배포", "배포 실행 요청", f"{d['project_code']} {d['version']} {d['environment']}", {"accepted": result.accepted, "detail": result.detail})
|
|
if not result.accepted:
|
|
raise HTTPException(503, result.detail)
|
|
return {"ok": True, "run_id": run_id, "detail": result.detail}
|
|
|
|
|
|
@app.get("/api/audit")
|
|
def get_audit(request: Request, limit: int = 100, x_role: Optional[str] = Header(default=None)):
|
|
actor(request, x_role)
|
|
limit = max(1, min(limit, 500))
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(select(audit_logs).order_by(audit_logs.c.id.desc()).limit(limit)).mappings().all()
|
|
return [{
|
|
"id": r["id"], "at": r["at"], "actor_role": r["actor_role"], "actor": role_name(r["actor_role"]),
|
|
"type": r["action_type"], "action": r["action"], "target": r["target"], "meta": json.loads(r["meta_json"] or "{}"),
|
|
} for r in rows]
|
|
|