admin 이 사장님 API(:9800)를 그대로 보고 있었다. 화면만 갈라 두면 내부 요청이 사장님이 닿는 서버로 나가고, 권한도 엔드포인트마다 흩어진 채로 남는다. ## 코드는 한 벌, 진입점만 둘 web_main.py → router/router.py :9800 사장님 admin_main.py → router/admin_router.py :9801 내부 services·crud·models 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다: admin 화면이 부르는 훅(useGetPlace·useListPlaces·useListLinks·useConfirmLink· useListFacts·useGetSchema·useTransitionFact)이 전부 place·fact 라우터이고, 그 둘은 사장님 빌더도 쓴다. 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌 구현하는 것뿐이라, 같은 router 객체를 다시 마운트하고 앱 단위로 권한만 덧걸었다. ## 왜 경로 접두어가 아니라 포트인가 /v1/admin/... 는 같은 프로세스 안이라 **사장님이 닿는 서버에 내부 엔드포인트가 존재한다.** 포트를 가르면 사장님이 닿는 네트워크에 아예 없다. compose 에서 이 포트는 127.0.0.1 에만 연다(ADMIN_API_BIND) — 0.0.0.0 으로 열면 가른 의미가 없다. ## 권한 RequireDeveloper 를 앱 단위로 건다. auth 라우터만 게이트 밖이다 — 로그인 자체를 막으면 아무도 들어올 수 없다. 검증 /v1/place/list : USER(1) 403 · OWNER(2) 403 · DEVELOPER(3) 200 OWNER 가 막히는 게 핵심이다. 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. ## 그 밖 - 이미지의 HEALTHCHECK 는 :9800 을 찌른다. 그대로 두면 이 컨테이너가 멀쩡히 돌면서 영원히 unhealthy 라, 포트만 바꿔 다시 걸었다. - compose 주석에 negosium-db 가 나오는 이유를 적었다 — 베낀 흔적이 아니라 DB 인스턴스를 따로 안 띄우고 그 postgres 안에 web4ai_db 만 만들어 쓰기 때문이다(DECISIONS.md 3절). 줄이면서 이유를 날려 읽는 사람이 오해하게 만들었다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""내부 운영 API (admin 앱 전용). 사장님 API(:9800)와 **프로세스와 포트가 갈린다.**
|
|
|
|
★ 왜 라우터를 새로 쓰지 않고 같은 것을 다시 마운트하나
|
|
admin 화면이 부르는 API 는 전부 place·fact 라우터에 이미 있다(세어봤다: admin 전용
|
|
라우터는 0개다). 여기서 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌
|
|
구현하게 된다. 그래서 **같은 router 객체를 그대로 include 하고, 앱 단위로 권한만 덧건다.**
|
|
|
|
★ 왜 경로 접두어(/v1/admin/...)가 아니라 포트를 가르나
|
|
접두어는 같은 프로세스 안에 있다 — 사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.**
|
|
포트를 가르면 내부 API 는 사장님이 닿는 네트워크에 아예 없다. 가드보다 강하다.
|
|
(compose 에서 이 포트는 127.0.0.1 에만 연다. 0.0.0.0 으로 열면 가른 의미가 없다.)
|
|
|
|
★ 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
|
|
OWNER 는 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니라서 막힌다
|
|
(RequireDeveloper 주석 참조). 예외를 한 곳이라도 두면 그 예외가 기본값이 된다.
|
|
"""
|
|
|
|
import time
|
|
|
|
from fastapi import Depends, FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
from config.server_configs import web_server_config
|
|
from router.v1.validator.dependencies import RequireDeveloper
|
|
import router.v1.auth.account
|
|
import router.v1.fact.fact
|
|
import router.v1.job.job
|
|
import router.v1.local.local
|
|
import router.v1.place.place
|
|
import router.v1.site.site
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
API_SERVER_START_TIME = GTime.UTCStr()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# ★ 스케줄러를 여기서 기동하지 않는다. 크론은 :9800 컨테이너 담당이고,
|
|
# 두 프로세스가 같이 돌면 같은 시각에 중복 실행된다.
|
|
yield
|
|
await DB_SESSION_MNG.dispose_all()
|
|
|
|
|
|
app = FastAPI(title="Web4Ai Admin API", lifespan=lifespan)
|
|
|
|
|
|
def _origins(*values: str) -> list[str]:
|
|
seen: list[str] = []
|
|
for value in values:
|
|
for origin in value.split(","):
|
|
origin = origin.strip().rstrip("/")
|
|
if origin and origin not in seen:
|
|
seen.append(origin)
|
|
return seen
|
|
|
|
|
|
ALLOWED_ORIGINS = _origins(web_server_config.client_url)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=ALLOWED_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def log_time(request: Request, call_next):
|
|
start_time = time.time()
|
|
response = await call_next(request)
|
|
LOG.d(f"[admin] {response.status_code} {request.method} {request.url.path} - {time.time() - start_time:.4f}s")
|
|
return response
|
|
|
|
|
|
@app.get(path="/healthz")
|
|
async def healthz():
|
|
return API_SERVER_START_TIME
|
|
|
|
|
|
# ★ auth 만 게이트 밖이다 — 로그인 자체를 막으면 아무도 들어올 수 없다.
|
|
# (로그인은 되지만 role 이 낮으면 아래 라우터가 전부 403 이다.)
|
|
app.include_router(router.v1.auth.account.router)
|
|
|
|
# 그 밖의 전부: 앱 단위 DEVELOPER 게이트.
|
|
_gate = [Depends(RequireDeveloper)]
|
|
app.include_router(router.v1.place.place.router, dependencies=_gate)
|
|
app.include_router(router.v1.fact.fact.router, dependencies=_gate)
|
|
app.include_router(router.v1.job.job.router, dependencies=_gate)
|
|
app.include_router(router.v1.site.site.router, dependencies=_gate)
|
|
app.include_router(router.v1.local.local.router, dependencies=_gate)
|