manual-deploy 2026-06-07 21:13
This commit is contained in:
parent
206a55ad9c
commit
738afda78a
@ -4,6 +4,12 @@
|
|||||||
# ── 네트워크 모드 ─────────────────────────────────────────────────────────────
|
# ── 네트워크 모드 ─────────────────────────────────────────────────────────────
|
||||||
GUARDIA_NETWORK_MODE=open
|
GUARDIA_NETWORK_MODE=open
|
||||||
|
|
||||||
|
# 나라장터(G2B) 공공데이터포털 서비스키 — 입찰 적합성 분석(g2b_opportunity)·
|
||||||
|
# 입찰 모니터(bid_watcher) 실시간 수집 기능에서 공유 사용. 비어 있으면 자동으로
|
||||||
|
# 폐쇄망 샘플 데이터 모드로 전환되어 동작합니다(서비스 중단 없음).
|
||||||
|
# 발급: https://www.data.go.kr (조달청_나라장터 입찰공고정보서비스)
|
||||||
|
G2B_API_KEY=
|
||||||
|
|
||||||
# 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP)
|
# 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP)
|
||||||
# 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr
|
# 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr
|
||||||
GUARDIA_ALLOWED_ORIGINS=http://zioinfo.co.kr,https://zioinfo.co.kr
|
GUARDIA_ALLOWED_ORIGINS=http://zioinfo.co.kr,https://zioinfo.co.kr
|
||||||
|
|||||||
54
core/crypto.py
Normal file
54
core/crypto.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
GUARDiA ITSM — 자격증명 암호화 공통 유틸리티
|
||||||
|
|
||||||
|
외부 연동 자격증명(API 키·시크릿·토큰·SNMP 커뮤니티 문자열 등)을 DB에 평문으로
|
||||||
|
저장하지 않기 위한 AES-256-GCM 암호화/복호화 모듈. 여러 라우터(jira_sync·multicloud·
|
||||||
|
ncloud·public_api_hub·snmp_discovery·sso_provider·upstage_ocr 등)가 공유한다.
|
||||||
|
|
||||||
|
키 소스: GUARDIA_ENC_KEY 환경변수 (문자열을 UTF-8 인코딩 후 32바이트로 패딩/절단).
|
||||||
|
core/seed.py · routers/citizen_portal.py 와 동일한 컨벤션 — 운영 배포 시
|
||||||
|
반드시 32자 이상의 고유 값으로 설정할 것 (미설정 시 데모 기본값 사용).
|
||||||
|
저장 형식: base64(nonce[12] + ciphertext + GCM tag[16])
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
|
||||||
|
def _enc_key() -> bytes:
|
||||||
|
raw = os.environ.get("GUARDIA_ENC_KEY", "guardia-demo-key-32bytes-padding!").encode("utf-8")
|
||||||
|
return raw[:32].ljust(32, b"\x00")
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plain: str | None) -> str | None:
|
||||||
|
"""평문 자격증명을 AES-256-GCM으로 암호화하여 base64 문자열로 반환한다."""
|
||||||
|
if not plain:
|
||||||
|
return plain
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
ct = AESGCM(_enc_key()).encrypt(nonce, plain.encode("utf-8"), None)
|
||||||
|
return base64.b64encode(nonce + ct).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(enc_value: str | None) -> str | None:
|
||||||
|
"""encrypt_secret()으로 암호화된 문자열을 복호화한다.
|
||||||
|
|
||||||
|
평문으로 저장된 레거시 값(이번 암호화 적용 이전 데이터)이나 손상된 값을 만나면
|
||||||
|
예외를 전파하지 않고 입력값을 그대로 반환한다 — Fail-Safe 원칙(CLAUDE.md)에 따라
|
||||||
|
기존 연동이 끊기지 않도록 한다.
|
||||||
|
"""
|
||||||
|
if not enc_value:
|
||||||
|
return enc_value
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(enc_value, validate=True)
|
||||||
|
nonce, ct = raw[:12], raw[12:]
|
||||||
|
return AESGCM(_enc_key()).decrypt(nonce, ct, None).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return enc_value
|
||||||
|
|
||||||
|
|
||||||
|
# kubernetes.py 등에서 참조하는 이름과 호환되는 별칭
|
||||||
|
encrypt_password = encrypt_secret
|
||||||
|
decrypt_password = decrypt_secret
|
||||||
@ -37,6 +37,7 @@ from sqlalchemy import select, desc
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import get_current_user, require_admin_role
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret, decrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, SRRequest, SRStatus,
|
User, SRRequest, SRStatus,
|
||||||
@ -101,8 +102,7 @@ async def _jira_request(
|
|||||||
payload: Optional[dict] = None
|
payload: Optional[dict] = None
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Jira REST API 호출 (오류 시 None 반환, 예외 미전파)."""
|
"""Jira REST API 호출 (오류 시 None 반환, 예외 미전파)."""
|
||||||
# 저장된 암호화 토큰 복호화 (실제 구현 시 core.crypto.decrypt 사용)
|
token = decrypt_secret(config.api_token_enc)
|
||||||
token = config.api_token_enc # 복호화된 토큰 (모델에서 property로 제공)
|
|
||||||
auth = (config.email, token)
|
auth = (config.email, token)
|
||||||
url = f"{config.base_url.rstrip('/')}/rest/api/3{path}"
|
url = f"{config.base_url.rstrip('/')}/rest/api/3{path}"
|
||||||
try:
|
try:
|
||||||
@ -160,8 +160,7 @@ async def save_jira_config(
|
|||||||
)
|
)
|
||||||
cfg = existing.scalar_one_or_none()
|
cfg = existing.scalar_one_or_none()
|
||||||
|
|
||||||
# API 토큰 암호화 (실제 구현: core.crypto.encrypt)
|
enc_token = encrypt_secret(req.api_token)
|
||||||
enc_token = req.api_token # TODO: AES-256-GCM 암호화
|
|
||||||
|
|
||||||
if cfg:
|
if cfg:
|
||||||
cfg.base_url = req.base_url
|
cfg.base_url = req.base_url
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import get_current_user, require_admin_role
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, MultiCloudConfig
|
from models import User, MultiCloudConfig
|
||||||
|
|
||||||
@ -48,7 +49,7 @@ async def add_provider(req: ProviderCreate, db: AsyncSession = Depends(get_db),
|
|||||||
tenant_id=user.tenant_id, name=req.name,
|
tenant_id=user.tenant_id, name=req.name,
|
||||||
provider_type=req.provider_type, region=req.region,
|
provider_type=req.provider_type, region=req.region,
|
||||||
access_key=req.access_key,
|
access_key=req.access_key,
|
||||||
secret_key_enc=req.secret_key, # TODO: AES-256-GCM
|
secret_key_enc=encrypt_secret(req.secret_key),
|
||||||
extra_config=json.dumps(req.extra_config or {}),
|
extra_config=json.dumps(req.extra_config or {}),
|
||||||
is_active=True, created_at=datetime.utcnow(),
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -29,6 +29,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import get_current_user, require_admin_role
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret, decrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, NCloudConfig # 신규
|
from models import User, NCloudConfig # 신규
|
||||||
|
|
||||||
@ -56,7 +57,7 @@ async def _ncloud_request(config: NCloudConfig, method: str, path: str, params:
|
|||||||
"""NCloud API 호출."""
|
"""NCloud API 호출."""
|
||||||
timestamp = str(int(datetime.utcnow().timestamp() * 1000))
|
timestamp = str(int(datetime.utcnow().timestamp() * 1000))
|
||||||
url = f"{path}?{urlencode(params or {})}" if params else path
|
url = f"{path}?{urlencode(params or {})}" if params else path
|
||||||
sig = _ncloud_signature(method, url, timestamp, config.access_key, config.secret_key_enc)
|
sig = _ncloud_signature(method, url, timestamp, config.access_key, decrypt_secret(config.secret_key_enc))
|
||||||
headers = {
|
headers = {
|
||||||
"x-ncp-apigw-timestamp": timestamp,
|
"x-ncp-apigw-timestamp": timestamp,
|
||||||
"x-ncp-iam-access-key": config.access_key,
|
"x-ncp-iam-access-key": config.access_key,
|
||||||
@ -83,13 +84,13 @@ async def save_ncloud_config(
|
|||||||
cfg = row.scalar_one_or_none()
|
cfg = row.scalar_one_or_none()
|
||||||
if cfg:
|
if cfg:
|
||||||
cfg.access_key = req.access_key
|
cfg.access_key = req.access_key
|
||||||
cfg.secret_key_enc = req.secret_key # TODO: AES-256-GCM 암호화
|
cfg.secret_key_enc = encrypt_secret(req.secret_key)
|
||||||
cfg.region = req.region
|
cfg.region = req.region
|
||||||
else:
|
else:
|
||||||
cfg = NCloudConfig(
|
cfg = NCloudConfig(
|
||||||
tenant_id=user.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
access_key=req.access_key,
|
access_key=req.access_key,
|
||||||
secret_key_enc=req.secret_key,
|
secret_key_enc=encrypt_secret(req.secret_key),
|
||||||
region=req.region,
|
region=req.region,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import get_current_user, require_admin_role
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, PublicApiConfig
|
from models import User, PublicApiConfig
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ async def register_api(req: ApiRegisterRequest, db: AsyncSession = Depends(get_d
|
|||||||
cfg = PublicApiConfig(
|
cfg = PublicApiConfig(
|
||||||
tenant_id=user.tenant_id, api_id=req.api_id,
|
tenant_id=user.tenant_id, api_id=req.api_id,
|
||||||
name=catalog_item["name"], endpoint=catalog_item["endpoint"],
|
name=catalog_item["name"], endpoint=catalog_item["endpoint"],
|
||||||
api_key_enc=req.api_key, # TODO: AES-256-GCM
|
api_key_enc=encrypt_secret(req.api_key),
|
||||||
is_active=True, created_at=datetime.utcnow(),
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
db.add(cfg); await db.commit()
|
db.add(cfg); await db.commit()
|
||||||
|
|||||||
@ -24,6 +24,7 @@ from sqlalchemy import select, desc
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import get_current_user, require_admin_role
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, SNMPConfig, SNMPDevice
|
from models import User, SNMPConfig, SNMPDevice
|
||||||
|
|
||||||
@ -139,7 +140,7 @@ async def create_snmp_config(
|
|||||||
cfg = SNMPConfig(
|
cfg = SNMPConfig(
|
||||||
tenant_id=user.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
name=req.name,
|
name=req.name,
|
||||||
community_enc=req.community, # TODO: AES-256-GCM 암호화
|
community_enc=encrypt_secret(req.community),
|
||||||
version=req.version,
|
version=req.version,
|
||||||
ip_ranges=req.ip_ranges,
|
ip_ranges=req.ip_ranges,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@ -177,7 +178,7 @@ async def start_snmp_scan(
|
|||||||
# 기본 SNMP 설정 생성
|
# 기본 SNMP 설정 생성
|
||||||
cfg = SNMPConfig(
|
cfg = SNMPConfig(
|
||||||
tenant_id=user.tenant_id, name=f"scan_{datetime.utcnow().strftime('%Y%m%d%H%M')}",
|
tenant_id=user.tenant_id, name=f"scan_{datetime.utcnow().strftime('%Y%m%d%H%M')}",
|
||||||
community_enc=req.community, version=req.version,
|
community_enc=encrypt_secret(req.community), version=req.version,
|
||||||
is_active=True, created_at=datetime.utcnow(),
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
db.add(cfg)
|
db.add(cfg)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import create_access_token, get_current_user, require_admin_role
|
from core.auth import create_access_token, get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret, decrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, UserRole, SSOConfig, SSOSession # 신규 모델
|
from models import User, UserRole, SSOConfig, SSOSession # 신규 모델
|
||||||
|
|
||||||
@ -141,7 +142,7 @@ async def _exchange_code(config: SSOConfig, code: str, redirect_uri: str) -> Opt
|
|||||||
"code": code,
|
"code": code,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"client_id": config.client_id,
|
"client_id": config.client_id,
|
||||||
"client_secret": config.client_secret_enc, # 복호화 필요
|
"client_secret": decrypt_secret(config.client_secret_enc),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return r.json() if r.status_code == 200 else None
|
return r.json() if r.status_code == 200 else None
|
||||||
@ -237,7 +238,7 @@ async def create_sso_config(
|
|||||||
idp_sso_url=req.idp_sso_url,
|
idp_sso_url=req.idp_sso_url,
|
||||||
idp_cert=req.idp_cert,
|
idp_cert=req.idp_cert,
|
||||||
client_id=req.client_id,
|
client_id=req.client_id,
|
||||||
client_secret_enc=req.client_secret, # TODO: AES-256-GCM 암호화
|
client_secret_enc=encrypt_secret(req.client_secret),
|
||||||
discovery_url=req.discovery_url,
|
discovery_url=req.discovery_url,
|
||||||
scopes=req.scopes,
|
scopes=req.scopes,
|
||||||
attribute_mapping=json.dumps(req.attribute_mapping),
|
attribute_mapping=json.dumps(req.attribute_mapping),
|
||||||
|
|||||||
@ -31,6 +31,7 @@ from sqlalchemy import select, func, desc
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.auth import get_current_user, require_admin_role
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from core.crypto import encrypt_secret, decrypt_secret
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, UpstageOCRConfig, OCRHistory
|
from models import User, UpstageOCRConfig, OCRHistory
|
||||||
|
|
||||||
@ -170,13 +171,13 @@ async def save_ocr_config(
|
|||||||
)
|
)
|
||||||
cfg = row.scalar_one_or_none()
|
cfg = row.scalar_one_or_none()
|
||||||
if cfg:
|
if cfg:
|
||||||
cfg.api_key_enc = req.api_key # TODO: AES-256-GCM 암호화
|
cfg.api_key_enc = encrypt_secret(req.api_key)
|
||||||
cfg.model = req.model
|
cfg.model = req.model
|
||||||
cfg.daily_limit = req.daily_limit
|
cfg.daily_limit = req.daily_limit
|
||||||
else:
|
else:
|
||||||
cfg = UpstageOCRConfig(
|
cfg = UpstageOCRConfig(
|
||||||
tenant_id=user.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
api_key_enc=req.api_key,
|
api_key_enc=encrypt_secret(req.api_key),
|
||||||
model=req.model,
|
model=req.model,
|
||||||
daily_limit=req.daily_limit,
|
daily_limit=req.daily_limit,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user