feat(auth): 관리자 판별을 Keycloak 역할(role)에서 그룹(group) 기반으로 변경

- auth.js: realm_access.roles/resource_access 대신 ID 토큰 claims.groups 사용
  (group-membership 매퍼, full.path=false 대비 앞 슬래시 제거 후 비교)
- config.js: adminRole(ADMIN_ROLE, "admin") → adminGroup(ADMIN_GROUP, "DevOps")
- server.js: 세션 user 주석 roles → groups
- CLAUDE.md: 인증 모델·Kubero Env 문서 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:47:34 +09:00
parent ac545651e6
commit b4c9b4a395
4 changed files with 13 additions and 16 deletions

View File

@@ -45,8 +45,9 @@ AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이
- `.claude/` — Claude Code 훅·설정 - `.claude/` — Claude Code 훅·설정
## 인증 모델 ## 인증 모델
- **사번 = Keycloak `preferred_username`.** 세션에 `req.session.user = { sabun, name, email, roles, isAdmin }`. - **사번 = Keycloak `preferred_username`.** 세션에 `req.session.user = { sabun, name, email, groups, isAdmin }`.
- **관리자 = `realm_access.roles``ADMIN_ROLE`(기본 `admin`) 포함.** 인프라 도구(OpenEverest/MinIO/ - **관리자 = `groups` 클레임`ADMIN_GROUP`(기본 `DevOps`) 포함.** (portal 클라이언트의 group-membership
매퍼가 ID 토큰에 `groups` 를 넣어줌. `full.path=false` 라 앞 슬래시는 떼고 비교.) 인프라 도구(OpenEverest/MinIO/
Keycloak/LiteLLM)는 관리자에게만 렌더(`sites.js``adminOnly` + `toolsForUser`). Keycloak/LiteLLM)는 관리자에게만 렌더(`sites.js``adminOnly` + `toolsForUser`).
- 미인증으로 보호 페이지 진입 시 중간 화면 없이 곧장 Keycloak 로그인으로 리다이렉트 - 미인증으로 보호 페이지 진입 시 중간 화면 없이 곧장 Keycloak 로그인으로 리다이렉트
(`requireAuth``loginRedirect`). OIDC discovery 실패 시에만 `login.ejs` 폴백. (`requireAuth``loginRedirect`). OIDC discovery 실패 시에만 `login.ejs` 폴백.
@@ -59,7 +60,7 @@ AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이
- `Dockerfile` 은 Kubero buildpack 의 harbor push 실패를 우회하는 **수동 push 용**. - `Dockerfile` 은 Kubero buildpack 의 harbor push 실패를 우회하는 **수동 push 용**.
- 도메인 `portal.bokdev.in` (`deploy/portal-route.yaml`). 도메인 추가 시 Keycloak `portal` - 도메인 `portal.bokdev.in` (`deploy/portal-route.yaml`). 도메인 추가 시 Keycloak `portal`
client redirect 에 `https://<도메인>/auth/callback` 등록 필요. client redirect 에 `https://<도메인>/auth/callback` 등록 필요.
- Kubero Env: `BASE_URL`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`, `ADMIN_ROLE`, (선택) 도구 `*_URL`. - Kubero Env: `BASE_URL`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`, `ADMIN_GROUP`, (선택) 도구 `*_URL`.
## 컨벤션 ## 컨벤션
- 라우트는 `src/server.js` 에, DB 접근은 `src/db.js` 의 함수로만 (인라인 SQL 산재 금지). - 라우트는 `src/server.js` 에, DB 접근은 `src/db.js` 의 함수로만 (인라인 SQL 산재 금지).

View File

@@ -42,20 +42,15 @@ export async function handleCallback(req) {
nonce, nonce,
}); });
const claims = tokenSet.claims(); const claims = tokenSet.claims();
// Keycloak realm 역할: realm_access.roles. (클라이언트 역할은 resource_access[client].roles) // 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다.
const realmRoles = (claims.realm_access && claims.realm_access.roles) || []; // full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교.
const clientRoles = const groups = (Array.isArray(claims.groups) ? claims.groups : []).map((g) => g.replace(/^\//, ""));
(claims.resource_access &&
claims.resource_access[config.oidc.clientId] &&
claims.resource_access[config.oidc.clientId].roles) ||
[];
const roles = [...new Set([...realmRoles, ...clientRoles])];
return { return {
sabun: claims.preferred_username, // 사번 sabun: claims.preferred_username, // 사번
name: claims.name || claims.given_name || claims.preferred_username, name: claims.name || claims.given_name || claims.preferred_username,
email: claims.email, email: claims.email,
roles, groups,
isAdmin: roles.includes(config.adminRole), isAdmin: groups.includes(config.adminGroup),
// SSO 로그아웃(end-session) 시 id_token_hint 로 사용 // SSO 로그아웃(end-session) 시 id_token_hint 로 사용
id_token: tokenSet.id_token, id_token: tokenSet.id_token,
}; };

View File

@@ -13,8 +13,9 @@ export const config = {
// redirect 는 baseUrl + /auth/callback 로 구성 // redirect 는 baseUrl + /auth/callback 로 구성
}, },
// 관리자 판별용 Keycloak 역할 이름. 이 role 을 가진 사용자에게만 인프라 도구 노출. // 관리자 판별용 Keycloak 그룹 이름. 이 그룹에 속한 사용자에게만 인프라 도구 노출.
adminRole: process.env.ADMIN_ROLE || "admin", // (groups 클레임은 portal 클라이언트의 group-membership 매퍼가 ID 토큰에 넣어줌)
adminGroup: process.env.ADMIN_GROUP || "DevOps",
// 프로젝트 등록 시 Gitea 언어 통계 API 로 메인 언어 자동 판별용 토큰(선택). // 프로젝트 등록 시 Gitea 언어 통계 API 로 메인 언어 자동 판별용 토큰(선택).
// 공개 레포는 없어도 되고, 비공개 레포 조회에만 필요. // 공개 레포는 없어도 되고, 비공개 레포 조회에만 필요.

View File

@@ -65,7 +65,7 @@ app.get("/auth/callback", async (req, res) => {
const { id_token, ...user } = u; const { id_token, ...user } = u;
req.session.idToken = id_token; req.session.idToken = id_token;
await db.upsertUser(user); await db.upsertUser(user);
req.session.user = user; // { sabun, name, email, roles, isAdmin } req.session.user = user; // { sabun, name, email, groups, isAdmin }
res.redirect("/"); res.redirect("/");
} catch (e) { } catch (e) {
console.error("oidc callback error", e); console.error("oidc callback error", e);