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 = """
NEXPA SourceHub LoginNEXPA
SOURCEHUB · INTERNAL SYSTEM
NEXPA Core 로그인
개발 소스, 승인, 배포 이력은 인증된 내부 사용자에게만 표시됩니다.
로그인 """
@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,
))
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]