feat(B01): 시스템 회사 자동 권한·회사관리자 권한 확대·재신청 방어
- 시스템 관리 회사(.env 관리자 계정 소속 회사) 소속은 전원 시스템관리자 — 서버 시작 시 일괄 반영, 가입 승인·팀원 등록 시에도 자동 부여 - 이미 소속이 있는 사용자의 회사 가입 재신청 차단 (기존: 신청 즉시 소속 해제·승인대기 전락) - 회사관리자에게 자기 회사 역할 변경·프로젝트 삭제 허용, 마지막 관리자 강등 차단 - 역할 변경 시 is_master 동반 조정 (권한 판정 불일치 제거) - 일반사용자도 소속 회사 프로젝트 전체 열람 (수정 권한은 별개) - 대시보드 시스템 로그 섹션 최하단 이동 - PLAN.md 에 B01 정비 계획 반영 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,37 @@ import aiomysql
|
||||
import psutil
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import EMAIL_REVERIFY_DAYS
|
||||
from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS
|
||||
|
||||
|
||||
def _role(value: str | None) -> str:
|
||||
return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER")
|
||||
|
||||
|
||||
async def get_system_company_id() -> int | None:
|
||||
"""시스템 관리 회사 = `.env` 관리자 계정이 속한 회사 (2026-09-06 사용자 확정).
|
||||
|
||||
개발사 자기 회사 한 곳뿐이라 따로 표시 칸을 두지 않고 이 한 줄로 판정한다.
|
||||
"""
|
||||
if not ADMIN_EMAIL:
|
||||
return None
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT company_id FROM users WHERE email = %s AND deleted_at IS NULL",
|
||||
(ADMIN_EMAIL.lower(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["company_id"]) if row and row["company_id"] else None
|
||||
|
||||
|
||||
async def role_for_company(company_id: int | None) -> str:
|
||||
"""회사에 들어갈 때 받는 역할 — 시스템 회사면 시스템관리자, 아니면 일반사용자."""
|
||||
if company_id is not None and int(company_id) == (await get_system_company_id() or 0):
|
||||
return "SYSTEM_ADMIN"
|
||||
return "USER"
|
||||
|
||||
|
||||
def _stage_from_status(status: str | None) -> tuple[int, int]:
|
||||
value = status or "NEW"
|
||||
if value in {"WF1_ANALYZING", "WF1_FAILED"}:
|
||||
@@ -392,11 +416,21 @@ async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str
|
||||
raise
|
||||
|
||||
|
||||
async def join_company(user_id: int, company_id: int) -> dict[str, Any]:
|
||||
async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
await connection.begin()
|
||||
# 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) —
|
||||
# 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다.
|
||||
await cursor.execute(
|
||||
"SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE",
|
||||
(user_id,),
|
||||
)
|
||||
current = await cursor.fetchone()
|
||||
if current and current[0]:
|
||||
await connection.rollback()
|
||||
return None
|
||||
await cursor.execute(
|
||||
"""INSERT INTO join_requests (user_id, company_id, status)
|
||||
VALUES (%s, %s, 'PENDING')
|
||||
@@ -456,10 +490,12 @@ async def process_join_request(
|
||||
(status, reviewer_id, request_id),
|
||||
)
|
||||
if approved:
|
||||
# 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정).
|
||||
role = await role_for_company(int(row["company_id"]))
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE'
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s
|
||||
WHERE id = %s""",
|
||||
(row["company_id"], row["user_id"]),
|
||||
(row["company_id"], role, row["user_id"]),
|
||||
)
|
||||
else:
|
||||
await cursor.execute(
|
||||
@@ -503,7 +539,11 @@ async def list_all_users() -> list[dict[str, Any]]:
|
||||
async def change_user_role(user_id: int, role: str) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id))
|
||||
# is_master 도 권한 판정에 쓰이므로 역할과 어긋나지 않게 함께 맞춘다.
|
||||
await cursor.execute(
|
||||
"UPDATE users SET role = %s, is_master = %s WHERE id = %s",
|
||||
(role, role in ("ADMIN", "SYSTEM_ADMIN"), user_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi import HTTPException
|
||||
from common_util.common_util_auth import hash_password
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
from .B01_Dashboard_Repository import _role, get_dashboard_me
|
||||
from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company
|
||||
from .B01_Dashboard_Repository_Assets import list_company_assets
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ async def add_company_member(
|
||||
await cursor.execute(
|
||||
"""INSERT INTO users (email, password_hash, name, position, department,
|
||||
company_id, role, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""",
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'PENDING')""",
|
||||
(
|
||||
email.lower(),
|
||||
hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호
|
||||
@@ -66,6 +66,7 @@ async def add_company_member(
|
||||
profile.get("position"),
|
||||
profile.get("department"),
|
||||
company_id,
|
||||
await role_for_company(company_id),
|
||||
),
|
||||
)
|
||||
await connection.commit()
|
||||
@@ -74,10 +75,10 @@ async def add_company_member(
|
||||
await connection.rollback()
|
||||
return None
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s,
|
||||
is_master = FALSE
|
||||
WHERE id = %s""",
|
||||
(company_id, user["id"]),
|
||||
(company_id, await role_for_company(company_id), user["id"]),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user["id"])
|
||||
|
||||
@@ -14,6 +14,7 @@ from config.config_system import PROJECT_DELETE_HARD_ENABLED
|
||||
from .B01_Dashboard_Repository import (
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
count_company_admins,
|
||||
create_company,
|
||||
get_dashboard_me,
|
||||
get_project,
|
||||
@@ -144,7 +145,14 @@ async def patch_dashboard_me(
|
||||
|
||||
@router.get("/user/projects")
|
||||
async def user_projects(session: dict[str, Any] = Depends(verify_session)):
|
||||
return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))}
|
||||
# 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다.
|
||||
company_id = session.get("company_id")
|
||||
projects = (
|
||||
await list_company_projects(int(company_id))
|
||||
if company_id
|
||||
else await list_user_projects(int(session["user_id"]))
|
||||
)
|
||||
return {"status": "success", "projects": projects}
|
||||
|
||||
|
||||
@router.get("/user/company")
|
||||
@@ -176,6 +184,11 @@ async def user_company_join(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
result = await join_company(int(session["user_id"]), payload.company_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="이미 회사에 소속돼 있습니다. 소속을 옮기려면 회사 관리자에게 요청하십시오.",
|
||||
)
|
||||
return {"status": "success", **result}
|
||||
|
||||
|
||||
@@ -286,14 +299,10 @@ async def dashboard_delete_project(
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
|
||||
can_del = False
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
can_del = True
|
||||
# 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리
|
||||
# elif session["role"] == "ADMIN":
|
||||
# if int(project.get("user_id") or 0) == int(session["user_id"]):
|
||||
# can_del = True
|
||||
|
||||
# 회사 관리자는 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정).
|
||||
can_del = session["role"] == "SYSTEM_ADMIN" or (
|
||||
session["role"] == "ADMIN" and _same_company(session, project.get("company_id"))
|
||||
)
|
||||
if not can_del:
|
||||
raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.")
|
||||
|
||||
@@ -329,14 +338,29 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
async def system_change_role(
|
||||
user_id: int,
|
||||
payload: ChangeUserRoleRequest,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
target = await get_user_admin_target(user_id)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||
# 회사 관리자는 자기 회사 사람만 (2026-09-06 사용자 확정). 시스템관리자는 전역이다.
|
||||
if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")):
|
||||
raise HTTPException(status_code=403, detail="다른 회사 사용자는 바꿀 수 없습니다.")
|
||||
if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN":
|
||||
raise HTTPException(
|
||||
status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다."
|
||||
status_code=403,
|
||||
detail="시스템 관리자 역할은 시스템 관리 회사 소속 여부로 정해집니다.",
|
||||
)
|
||||
# 회사에 관리자가 하나도 남지 않게 되는 강등은 막는다 (본인 강등 포함).
|
||||
if (
|
||||
target["role"] == "ADMIN"
|
||||
and payload.role != "ADMIN"
|
||||
and target.get("company_id")
|
||||
and await count_company_admins(int(target["company_id"])) <= 1
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.",
|
||||
)
|
||||
if not await change_user_role(user_id, payload.role):
|
||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||
|
||||
@@ -7,9 +7,8 @@ export function canEditProject(user: DashboardUser, _project: ProjectItem): bool
|
||||
}
|
||||
|
||||
export function canDeleteProject(user: DashboardUser): boolean {
|
||||
// SYSTEM_ADMIN만 가능 (ADMIN 프로젝트 삭제는 나중을 위해 주석 처리)
|
||||
return user.role === "SYSTEM_ADMIN";
|
||||
// return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN";
|
||||
// 회사 관리자도 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). 범위는 백엔드가 다시 본다.
|
||||
return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null);
|
||||
}
|
||||
|
||||
export function canAddUser(user: DashboardUser): boolean {
|
||||
@@ -17,8 +16,9 @@ export function canAddUser(user: DashboardUser): boolean {
|
||||
}
|
||||
|
||||
export function canChangeRole(user: DashboardUser, _targetUser: Member | DashboardUser): boolean {
|
||||
// 역할 변경은 오직 SYSTEM_ADMIN만 가능
|
||||
return user.role === "SYSTEM_ADMIN";
|
||||
// 회사 관리자도 자기 회사 안에서 역할을 바꾼다 (2026-09-06 사용자 확정).
|
||||
// 마지막 관리자 이탈·시스템 관리자 변경은 백엔드가 막는다.
|
||||
return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null);
|
||||
}
|
||||
|
||||
export function canDeleteUser(user: DashboardUser, _targetUser: Member | DashboardUser): boolean {
|
||||
|
||||
@@ -151,7 +151,6 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
|
||||
]),
|
||||
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
|
||||
);
|
||||
} else if (state.user.role === "ADMIN") {
|
||||
grid.append(
|
||||
@@ -181,6 +180,10 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
||||
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
||||
);
|
||||
// 시스템 로그는 맨 아래 (2026-09-06 사용자 지시).
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
|
||||
}
|
||||
page.append(grid);
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -278,6 +278,16 @@ async def lifespan(app: FastAPI):
|
||||
await cursor.execute(
|
||||
"UPDATE users SET role = 'SYSTEM_ADMIN' WHERE email = %s", (ADMIN_EMAIL.lower(),)
|
||||
)
|
||||
# 시스템 관리 회사(관리자 계정이 속한 회사) 소속은 전원 시스템관리자다
|
||||
# (2026-09-06 사용자 확정) — 역할을 따로 고를 일이 없다.
|
||||
await cursor.execute(
|
||||
"""UPDATE users u
|
||||
JOIN users a ON a.email = %s AND a.deleted_at IS NULL
|
||||
AND a.company_id IS NOT NULL
|
||||
SET u.role = 'SYSTEM_ADMIN'
|
||||
WHERE u.company_id = a.company_id AND u.deleted_at IS NULL""",
|
||||
(ADMIN_EMAIL.lower(),),
|
||||
)
|
||||
await connection.commit()
|
||||
cleanup_task = asyncio.create_task(cleanup_expired_sessions())
|
||||
resource_task = asyncio.create_task(sample_resources_loop())
|
||||
|
||||
Reference in New Issue
Block a user