feat(B01): 시스템 회사 자동 권한·회사관리자 권한 확대·재신청 방어

- 시스템 관리 회사(.env 관리자 계정 소속 회사) 소속은 전원 시스템관리자 — 서버 시작 시 일괄 반영, 가입 승인·팀원 등록 시에도 자동 부여
- 이미 소속이 있는 사용자의 회사 가입 재신청 차단 (기존: 신청 즉시 소속 해제·승인대기 전락)
- 회사관리자에게 자기 회사 역할 변경·프로젝트 삭제 허용, 마지막 관리자 강등 차단
- 역할 변경 시 is_master 동반 조정 (권한 판정 불일치 제거)
- 일반사용자도 소속 회사 프로젝트 전체 열람 (수정 권한은 별개)
- 대시보드 시스템 로그 섹션 최하단 이동
- PLAN.md 에 B01 정비 계획 반영

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 11:16:25 +09:00
co-authored by Claude Opus 5
parent c3dc1f4395
commit 5502f95040
6 changed files with 104 additions and 26 deletions
+45 -5
View File
@@ -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"])
+35 -11
View File
@@ -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="사용자를 찾을 수 없습니다.")
+5 -5
View File
@@ -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 {
+4 -1
View File
@@ -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;
}
+10
View File
@@ -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())