diff --git a/README.md b/README.md index 7af84ca1..35fabe7a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ See mc-admin-cli's [README](https://github.com/m-cmp/mc-admin-cli#readme) (Quick mc-web-console runs as **two separate Echo servers**: `api/` (backend API / proxy to mc-iam-manager, mc-infra-manager, and the other microservices, default port 3000) and `front/` (HTML templates + static assets, default port 3001). +**API access** — `api/` (3000) is a public API, not only a backend for `front/`: other frameworks and clients sign in with `POST /api/auth/login` and call the services with the returned `access_token`. Every `api/` route except `/readyz` and `/api/auth/{login,refresh,signup}` requires that token (`Authorization: Bearer `); requests without a valid token get `401`. With `MC_WEB_CONSOLE_USE_IAM=true` the token is verified against mc-iam-manager's `/api/auth/certs`, otherwise against the local `MC_WEB_CONSOLE_JWT_SECRET`. Browsers use `front/` (3001), which forwards `/api/*` to `api/` with the signed-in user's token. + **Prerequisites** - Go 1.25+ ([install guide](https://go.dev/doc/install)) - Node.js (for `front/`'s webpack build) diff --git a/api/cmd/main.go b/api/cmd/main.go index 161b4d49..6567812c 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -118,12 +118,16 @@ func main() { authProtected.POST("/logout", handler.Logout) authProtected.GET("/userinfo", handler.UserInfo) + // 이하 라우트는 front(ApiCaller)를 거치지 않고 API 포트로 직접 호출될 수 있으므로 + // 요청마다 토큰을 검증한다 (front의 IsTokenExistMiddleware는 쿠키 존재만 확인). + protect := middleware.ProxyAuthMiddleware + // 단일 세그먼트 내부 핸들러 - api.POST("/disklookup", handler.DiskLookup) - api.POST("/getapihosts", handler.GetApiHosts) + api.POST("/disklookup", handler.DiskLookup, protect) + api.POST("/getapihosts", handler.GetApiHosts, protect) // Async request history (WEB-TECH-017) — must register before subsystem wildcard - asyncReq := api.Group("/async-requests") + asyncReq := api.Group("/async-requests", protect) asyncReq.GET("", handler.ListAsyncRequests) asyncReq.DELETE("", handler.ClearFinishedAsyncRequests) asyncReq.PATCH("/:requestId", handler.PatchAsyncRequest) @@ -135,22 +139,21 @@ func main() { // 관리자 전용 BFF 라우트 (와일드카드보다 먼저 등록되어야 정적 매칭됨) // FR-CLOUD-ADMIN-006-08: 외부 raw YAML 도달성 확인 (CORS 우회 + 토큰 노출 방지) - // 인증 모델: SubsystemAnyController와 동일 — front Buffalo의 IsTokenExistMiddleware(cookie 검증) - // 가 1차 게이트 역할을 하므로 BFF는 별도 JWT 검증을 수행하지 않는다. - adminBFF := api.Group("/admin") + adminBFF := api.Group("/admin", protect) adminBFF.GET("/setup-yaml-check", handler.GetSetupYamlCheck) // 서브시스템 프록시 라우트 (Buffalo SubsystemAnyController 호환) // POST /api/:subsystemName/:operationId → conf/api.yaml 기반으로 백엔드 서비스에 프록시 - api.Any("/:subsystemName/:operationId", handler.SubsystemAnyController) + api.Any("/:subsystemName/:operationId", handler.SubsystemAnyController, protect) - // 테스트 엔드포인트들 + // 테스트 엔드포인트들 (generate-token이 로컬 서명 토큰을 발급하므로 함께 보호) + test := api.Group("", protect) // @Summary Hello // @Tags test // @Produce json // @Success 200 {object} map[string]interface{} // @Router /api/hello [get] - api.GET("/hello", func(c echo.Context) error { + test.GET("/hello", func(c echo.Context) error { requestID := middleware.GetRequestID(c) return c.JSON(200, map[string]interface{}{ "message": "Hello from Echo API!", @@ -159,7 +162,7 @@ func main() { }) }) - api.GET("/test/response", func(c echo.Context) error { + test.GET("/test/response", func(c echo.Context) error { resp := model.CommonResponseStatusOK(map[string]interface{}{ "test": "data", "items": []string{"item1", "item2"}, @@ -168,7 +171,7 @@ func main() { }) // JWT 토큰 생성 테스트 - api.POST("/test/generate-token", func(c echo.Context) error { + test.POST("/test/generate-token", func(c echo.Context) error { token, err := jwt.GenerateToken("testuser", "Test User", "test@example.com", "user", 3600*1000000000) if err != nil { return errors.NewInternalServerError("Failed to generate token", err) @@ -179,8 +182,7 @@ func main() { return c.JSON(resp.Status.Code, resp) }) - // 공개 테스트 엔드포인트 (인증 불필요) - api.GET("/test/error", func(c echo.Context) error { + test.GET("/test/error", func(c echo.Context) error { return errors.NewBadRequest("This is a test error") }) diff --git a/api/internal/middleware/auth.go b/api/internal/middleware/auth.go index 167a7ed2..fd015c32 100644 --- a/api/internal/middleware/auth.go +++ b/api/internal/middleware/auth.go @@ -1,10 +1,13 @@ package middleware import ( + "log" + "mc_web_console_api/internal/config" "mc_web_console_api/internal/repository" "mc_web_console_api/pkg/errors" "mc_web_console_api/pkg/jwt" "strings" + "sync" "github.com/labstack/echo/v4" ) @@ -44,6 +47,90 @@ func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { } } +var ( + iamKeySetMu sync.Mutex + iamKeySet *jwt.KeySet +) + +// getIAMKeySet mc-iam-manager 공개키 캐시 (api.yaml의 mc-iam-manager Getcerts 경로 기준) +func getIAMKeySet(cfg *config.Config) (*jwt.KeySet, error) { + service, action, err := cfg.ApiSpec.GetAction("mc-iam-manager", "Getcerts") + if err != nil { + return nil, err + } + certsURL := service.BaseURL + action.ResourcePath + + iamKeySetMu.Lock() + defer iamKeySetMu.Unlock() + if iamKeySet == nil || iamKeySet.URL() != certsURL { + iamKeySet = jwt.NewKeySet(certsURL) + } + return iamKeySet, nil +} + +// ProxyAuthMiddleware 서브시스템 프록시·BFF 라우트 인증 미들웨어 +// - MC_WEB_CONSOLE_USE_IAM=true: mc-iam-manager 발급 토큰(RS256)을 /api/auth/certs 공개키로 검증 +// - false: 로컬 발급 토큰(HS256) 검증 + DB 세션 확인 (AuthMiddleware와 동일) +// front(ApiCaller)는 쿠키 토큰을 "Bearer " 없이 Authorization 헤더에 싣으므로 두 형식 모두 허용한다. +// 프록시가 role로 x-credential-holder를 정하므로 role은 컨텍스트에 설정하지 않는다 (기존 동작 유지). +func ProxyAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + token := proxyToken(c) + if token == "" { + return errors.NewUnauthorized("Missing authorization token") + } + + cfg, _ := c.Get("config").(*config.Config) + if cfg != nil && cfg.MCIAM.Use { + keySet, err := getIAMKeySet(cfg) + if err != nil { + return errors.NewInternalServerError("MCIAM certs config not found", err) + } + claims, err := keySet.ParseIAMToken(token) + if err != nil { + log.Printf("ProxyAuthMiddleware: %s %s rejected: %v", c.Request().Method, c.Request().URL.Path, err) + return errors.NewUnauthorized("Invalid token") + } + c.Set("userId", claims.UserID()) + c.Set("userName", claims.Name) + c.Set("email", claims.Email) + } else { + claims, err := jwt.ParseToken(token) + if err != nil { + return errors.NewUnauthorized("Invalid token") + } + if db := repository.GetDB(); db != nil { + sessionRepo := repository.NewSessionRepository(db) + exists, err := sessionRepo.Exists(claims.UserID) + if err != nil || !exists { + return errors.NewUnauthorized("Session not found") + } + } + c.Set("userId", claims.UserID) + c.Set("userName", claims.UserName) + c.Set("email", claims.Email) + } + + return next(c) + } +} + +// proxyToken "Bearer " 또는 토큰 원문 형식의 Authorization 헤더에서 토큰 추출 +func proxyToken(c echo.Context) string { + authHeader := strings.TrimSpace(c.Request().Header.Get("Authorization")) + if authHeader == "" { + return "" + } + parts := strings.Fields(authHeader) + switch { + case len(parts) == 2 && parts[0] == "Bearer": + return parts[1] + case len(parts) == 1: + return parts[0] + } + return "" +} + // extractToken Authorization 헤더에서 토큰 추출 func extractToken(c echo.Context) string { authHeader := c.Request().Header.Get("Authorization") diff --git a/api/internal/middleware/auth_test.go b/api/internal/middleware/auth_test.go new file mode 100644 index 00000000..99a30760 --- /dev/null +++ b/api/internal/middleware/auth_test.go @@ -0,0 +1,161 @@ +package middleware + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "mc_web_console_api/internal/config" + "mc_web_console_api/pkg/errors" + pkgjwt "mc_web_console_api/pkg/jwt" + + gojwt "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +const testKid = "test-kid" + +func newCertsServer(t *testing.T, key *rsa.PublicKey) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/auth/certs" { + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "keys": []map[string]string{{ + "kid": testKid, + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }}, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +func iamConfig(baseURL string) *config.Config { + return &config.Config{ + MCIAM: config.MCIAMConfig{Use: true}, + ApiSpec: &config.ApiSpec{ + Services: map[string]config.Service{ + "mc-iam-manager": {BaseURL: baseURL, Auth: config.AuthConfig{Type: "bearer"}}, + }, + ServiceActions: map[string]map[string]config.ActionSpec{ + "mc-iam-manager": {"Getcerts": {Method: "get", ResourcePath: "/api/auth/certs"}}, + }, + }, + } +} + +func signRS256(t *testing.T, key *rsa.PrivateKey, kid string, exp time.Time) string { + t.Helper() + tok := gojwt.NewWithClaims(gojwt.SigningMethodRS256, gojwt.MapClaims{ + "preferred_username": "mzc-noh", + "email": "noh@example.com", + "exp": exp.Unix(), + }) + tok.Header["kid"] = kid + s, err := tok.SignedString(key) + if err != nil { + t.Fatalf("sign: %v", err) + } + return s +} + +// runProxyAuth 미들웨어를 통과하면 200과 userId, 거부되면 AppError 코드를 반환한다. +func runProxyAuth(t *testing.T, cfg *config.Config, authHeader string) (int, string) { + t.Helper() + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/api/mc-infra-manager/GetCredentialHolderList", nil) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("config", cfg) + + var userID string + err := ProxyAuthMiddleware(func(c echo.Context) error { + userID, _ = c.Get("userId").(string) + return c.NoContent(http.StatusOK) + })(c) + if err != nil { + appErr, ok := err.(*errors.AppError) + if !ok { + t.Fatalf("unexpected error type %T: %v", err, err) + } + return appErr.Code, "" + } + return rec.Code, userID +} + +func TestProxyAuthMiddleware_IAMMode(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + otherKey, _ := rsa.GenerateKey(rand.Reader, 2048) + srv := newCertsServer(t, &key.PublicKey) + cfg := iamConfig(srv.URL) + + valid := signRS256(t, key, testKid, time.Now().Add(time.Hour)) + + cases := []struct { + name string + header string + wantCode int + }{ + {"no token", "", http.StatusUnauthorized}, + {"malformed token", "Bearer abc.def.ghi", http.StatusUnauthorized}, + {"signed by other key", "Bearer " + signRS256(t, otherKey, testKid, time.Now().Add(time.Hour)), http.StatusUnauthorized}, + {"expired", "Bearer " + signRS256(t, key, testKid, time.Now().Add(-time.Minute)), http.StatusUnauthorized}, + {"local HS256 token", "Bearer " + mustLocalToken(t), http.StatusUnauthorized}, + {"valid bearer", "Bearer " + valid, http.StatusOK}, + {"valid raw (front ApiCaller format)", valid, http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, userID := runProxyAuth(t, cfg, tc.header) + if code != tc.wantCode { + t.Fatalf("code = %d, want %d", code, tc.wantCode) + } + if tc.wantCode == http.StatusOK && userID != "mzc-noh" { + t.Fatalf("userId = %q, want mzc-noh", userID) + } + }) + } +} + +func mustLocalToken(t *testing.T) string { + t.Helper() + tok, err := pkgjwt.GenerateToken("local", "Local", "local@example.com", "user", time.Hour) + if err != nil { + t.Fatal(err) + } + return tok +} + +func TestProxyAuthMiddleware_LocalMode(t *testing.T) { + cfg := &config.Config{MCIAM: config.MCIAMConfig{Use: false}} + + if code, _ := runProxyAuth(t, cfg, ""); code != http.StatusUnauthorized { + t.Fatalf("no token: code = %d, want 401", code) + } + if code, _ := runProxyAuth(t, cfg, "Bearer abc.def.ghi"); code != http.StatusUnauthorized { + t.Fatalf("forged token: code = %d, want 401", code) + } + code, userID := runProxyAuth(t, cfg, "Bearer "+mustLocalToken(t)) + if code != http.StatusOK || userID != "local" { + t.Fatalf("valid local token: code = %d userId = %q", code, userID) + } +} diff --git a/api/pkg/jwt/jwks.go b/api/pkg/jwt/jwks.go new file mode 100644 index 00000000..9ec7d8a1 --- /dev/null +++ b/api/pkg/jwt/jwks.go @@ -0,0 +1,151 @@ +package jwt + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// IAMClaims mc-iam-manager(Keycloak)가 발급한 access token의 클레임 중 사용하는 값 +type IAMClaims struct { + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// UserID 로그인 ID (preferred_username, 없으면 sub) +func (c *IAMClaims) UserID() string { + if c.PreferredUsername != "" { + return c.PreferredUsername + } + return c.Subject +} + +// jwkSet GET /api/auth/certs 응답 (Keycloak JWKS) +type jwkSet struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` +} + +// minRefetchInterval 모르는 kid로 인한 certs 재조회 최소 간격 (위조 토큰 반복 호출로 IAM에 부하를 주지 않도록) +const minRefetchInterval = 30 * time.Second + +// KeySet mc-iam-manager 공개키 캐시. 서명 키 교체(kid 변경) 시 자동 재조회한다. +type KeySet struct { + certsURL string + client *http.Client + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +// NewKeySet certsURL 예: http://mc-iam-manager:5000/api/auth/certs +func NewKeySet(certsURL string) *KeySet { + return &KeySet{ + certsURL: certsURL, + client: &http.Client{Timeout: 10 * time.Second}, + keys: map[string]*rsa.PublicKey{}, + } +} + +// URL certs 조회 경로 +func (ks *KeySet) URL() string { + return ks.certsURL +} + +func (ks *KeySet) publicKey(kid string) (*rsa.PublicKey, error) { + ks.mu.Lock() + defer ks.mu.Unlock() + + if key, ok := ks.keys[kid]; ok { + return key, nil + } + if !ks.fetchedAt.IsZero() && time.Since(ks.fetchedAt) < minRefetchInterval { + return nil, fmt.Errorf("unknown key id: %s", kid) + } + keys, err := ks.fetch() + ks.fetchedAt = time.Now() + if err != nil { + return nil, err + } + ks.keys = keys + if key, ok := ks.keys[kid]; ok { + return key, nil + } + return nil, fmt.Errorf("unknown key id: %s", kid) +} + +func (ks *KeySet) fetch() (map[string]*rsa.PublicKey, error) { + resp, err := ks.client.Get(ks.certsURL) + if err != nil { + return nil, fmt.Errorf("fetch certs: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch certs: status %d", resp.StatusCode) + } + + var set jwkSet + if err := json.Unmarshal(body, &set); err != nil { + return nil, fmt.Errorf("parse certs: %w", err) + } + + keys := map[string]*rsa.PublicKey{} + for _, k := range set.Keys { + if k.Kty != "RSA" || (k.Use != "" && k.Use != "sig") { + continue + } + n, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + continue + } + e, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + continue + } + keys[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(n), + E: int(new(big.Int).SetBytes(e).Int64()), + } + } + if len(keys) == 0 { + return nil, fmt.Errorf("no RSA signing keys in certs") + } + return keys, nil +} + +// ParseIAMToken RS256 서명과 만료를 검증하고 클레임을 반환한다. +func (ks *KeySet) ParseIAMToken(tokenString string) (*IAMClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &IAMClaims{}, func(token *jwt.Token) (interface{}, error) { + kid, _ := token.Header["kid"].(string) + if kid == "" { + return nil, fmt.Errorf("kid header not found") + } + return ks.publicKey(kid) + }, jwt.WithValidMethods([]string{"RS256"}), jwt.WithExpirationRequired()) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + claims, ok := token.Claims.(*IAMClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + return claims, nil +} diff --git a/conf/api.yaml b/conf/api.yaml index 8532379f..7f6f8bc2 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -926,6 +926,10 @@ serviceActions: method: get resourcePath: /api/auth/temp-credential-csps description: 임시 자격 증명을 지원하는 CSP 목록 조회 + GetTemporaryCredentials: + method: post + resourcePath: /api/workspaces/temporary-credentials + description: "로그인 사용자의 워크스페이스 역할에 매핑된 CSP Role로 CSP 임시 자격 증명 발급. body: {workspaceId, cspType, region, authMethod?}" Appendresourcepermissionpolicesbyoperationid: method: put resourcePath: /api/permission/framewrok/{framework}/operationid/{operationid}/append @@ -1098,6 +1102,10 @@ serviceActions: method: put resourcePath: /api/users/me/password description: 사용자 본인 비밀번호 변경 + GetMyWorkspaceRoles: + method: get + resourcePath: /api/users/me/workspace-roles + description: 로그인 사용자 본인의 워크스페이스별 역할 목록 Createorganization: method: post resourcePath: /api/organizations diff --git a/front/assets/js/common/api/http.js b/front/assets/js/common/api/http.js index d7fc489c..30627c0e 100644 --- a/front/assets/js/common/api/http.js +++ b/front/assets/js/common/api/http.js @@ -78,9 +78,12 @@ export async function commonAPIPost(url, data, attempt, options = {}) { attempt = false; } + // options.sensitive: 응답에 비밀값(임시자격증명, private key 등)이 들어 있는 호출 — 요청/응답 본문을 콘솔에 남기지 않는다 + const sensitive = options.sensitive === true; + console.log("#### commonAPIPost"); console.log("Request URL :", url); - console.log("Request Data :", JSON.stringify(data)); + console.log("Request Data :", sensitive ? "[redacted]" : JSON.stringify(data)); console.log("Loader Type :", loaderType); console.log("-----------------------"); @@ -122,7 +125,7 @@ export async function commonAPIPost(url, data, attempt, options = {}) { console.log("#### commonAPIPost Response"); console.log("Response status : ", response.status); - console.log("Response from : ", url, response.data); + console.log("Response from : ", url, sensitive ? "[redacted]" : response.data); console.log("----------------------------"); // Loader 종료 / End loader @@ -195,7 +198,9 @@ export async function commonAPIPost(url, data, attempt, options = {}) { } else if (loaderType === 'page') { deactivePageLoader(); } - webconsolejs["common/util"].showToast("Insufficient permissions. Please contact your administrator.", 'error'); + if (!options.silentErrors) { + webconsolejs["common/util"].showToast("Insufficient permissions. Please contact your administrator.", 'error'); + } throw error; } // 500 Internal Server Error는 서버 오류 @@ -207,7 +212,7 @@ export async function commonAPIPost(url, data, attempt, options = {}) { deactivePageLoader(); } // 호출부가 서버 메시지를 직접 노출하는 경우(options.suppressErrorToast) 일반 문구로 덮지 않는다. - if (!options.suppressErrorToast) { + if (!options.suppressErrorToast && !options.silentErrors) { webconsolejs["common/util"].showToast("Server error occurred. Please try again later.", 'error'); } throw error; @@ -221,6 +226,12 @@ export async function commonAPIPost(url, data, attempt, options = {}) { deactivePageLoader(); } + // options.silentErrors: 호출부가 모든 HTTP 오류(403·4xx 포함)의 사유를 직접 표시 — 공통 오류 toast를 띄우지 않는다 + // (suppressErrorToast는 500 toast만 억제한다) + if (options.silentErrors && error.response) { + throw error; + } + // 네트워크 오류나 기타 예외 상황 처리 if (!error.response) { // 네트워크 오류 (서버에 연결할 수 없음) diff --git a/front/assets/js/common/api/services/csp_credential_api.js b/front/assets/js/common/api/services/csp_credential_api.js new file mode 100644 index 00000000..209a59f9 --- /dev/null +++ b/front/assets/js/common/api/services/csp_credential_api.js @@ -0,0 +1,178 @@ +// CSP 임시 자격 증명(STS) 발급 API +// mc-iam-manager POST /api/workspaces/temporary-credentials — 프록시가 로그인 사용자의 IAM 토큰을 그대로 전달하므로 +// 발급 권한은 "이 사용자의 워크스페이스 역할 → CSP Role 매핑" 기준으로 IAM이 판단한다. +// +// 응답에는 비밀값이 들어 있으므로: +// - commonAPIPost에 sensitive 옵션을 넘겨 요청/응답 본문을 콘솔에 남기지 않는다 +// - 반환값을 storage에 저장하거나 console에 찍지 않는다 (호출부도 동일) + +// IAM이 403으로 돌려주는 "역할 매핑 없음"과 "CSP Role assume 실패"를 구분해 사용자 안내 문구로 바꾼다 +export class CspCredentialError extends Error { + constructor(message, { status, code, serverMessage } = {}) { + super(message); + this.name = 'CspCredentialError'; + this.status = status; + this.code = code; // 'NO_MAPPING' | 'ASSUME_FAILED' | 'NOT_FOUND' | 'BAD_REQUEST' | 'UNKNOWN' + this.serverMessage = serverMessage; + } +} + +function serverErrorText(err) { + const body = err?.response?.data; + return body?.responseData?.error || body?.responseData?.message || body?.error || body?.message || ''; +} + +export function toCspCredentialError(err, cspType) { + if (err instanceof CspCredentialError) return err; + const status = err?.response?.status; + const serverMessage = serverErrorText(err); + const csp = String(cspType || '').toUpperCase() || 'this CSP'; + + if (status === 403 && /No suitable CSP role mapping/i.test(serverMessage)) { + return new CspCredentialError( + `No CSP role is mapped to your role in this workspace for ${csp}. Ask an administrator.`, + { status, code: 'NO_MAPPING', serverMessage } + ); + } + if (status === 403) { + return new CspCredentialError( + `Failed to assume the CSP role mapped for ${csp}. Ask an administrator to check the CSP role and IdP settings.`, + { status, code: 'ASSUME_FAILED', serverMessage } + ); + } + if (status === 404) { + return new CspCredentialError( + 'The workspace or user was not found in IAM. Select a workspace and try again.', + { status, code: 'NOT_FOUND', serverMessage } + ); + } + if (status === 400) { + return new CspCredentialError( + `Temporary credentials are not supported for ${csp}${serverMessage ? ': ' + serverMessage : '.'}`, + { status, code: 'BAD_REQUEST', serverMessage } + ); + } + return new CspCredentialError( + `Failed to issue temporary credentials${serverMessage ? ': ' + serverMessage : (err?.message ? ': ' + err.message : '.')}`, + { status, code: 'UNKNOWN', serverMessage } + ); +} + +/** + * 로그인 사용자 기준 CSP 임시 자격 증명 발급 + * @param {{workspaceId: string|number, cspType: string, region?: string, authMethod?: string}} params + * @returns {Promise} CspCredentialResponse (cspType, accessKeyId, secretAccessKey, sessionToken, + * accessToken, tokenType, accessKeySecret, securityToken, expiration, region) + * @throws {CspCredentialError} + */ +export async function getTemporaryCredentials({ workspaceId, cspType, region, authMethod } = {}) { + const csp = String(cspType || '').toLowerCase(); + if (!workspaceId) { + throw new CspCredentialError('No workspace is selected. Select a workspace and try again.', { code: 'BAD_REQUEST' }); + } + if (!csp) { + throw new CspCredentialError('The CSP of this node could not be determined.', { code: 'BAD_REQUEST' }); + } + + const request = { workspaceId: String(workspaceId), cspType: csp, region: region || '' }; + if (authMethod) request.authMethod = authMethod; + + const controller = '/api/mc-iam-manager/GetTemporaryCredentials'; + let response; + try { + response = await webconsolejs['common/api/http'].commonAPIPost( + controller, + { request }, + undefined, + { sensitive: true, silentErrors: true } + ); + } catch (err) { + throw toCspCredentialError(err, csp); + } + // 방어: 과거 commonAPIPost는 일부 오류에서 throw 대신 axios error 객체를 반환했다 + if (!response || response.response || !response.data) { + throw toCspCredentialError(response || new Error('Empty response'), csp); + } + const cred = response.data.responseData; + if (!cred || typeof cred !== 'object') { + throw new CspCredentialError('Failed to issue temporary credentials: empty response.', { code: 'UNKNOWN' }); + } + return cred; +} + +// ─── 발급 가능 CSP 판정 (CSP 선택 목록용) ───────────────────────────────── +// 발급 근거는 platform 역할이 아니라 "현재 워크스페이스에서의 내 역할(직접 배정 우선, 없으면 그룹 상속)"이다 +// (mc-iam-manager csp_credential_service.GetTemporaryCredentials). 그 역할에 "설정된" CSP Role 매핑이 있는 CSP만 고를 수 있게 한다. +// 1) GET /api/users/me/workspace-roles → 현재 워크스페이스의 role_id (그룹 상속 포함 유효 역할) +// 2) GET /api/roles/csp-roles/id/{roleId} → cspRoles 중 status=created 이고 idp/iam identifier 가 있는 csp_type +// "설정됨" 판정일 뿐 발급 보장은 아니다(발급 후 403 등은 getTemporaryCredentials 쪽에서 안내). +// 조회 실패·역할 없음은 빈 목록. identifier 값은 판정에만 쓰고 보관·출력하지 않는다. +// IAM readiness API 도입 시 이 판정을 교체한다. + +const QUIET = { loaderType: 'none', sensitive: true, silentErrors: true }; +let issuableCache = { workspaceId: null, promise: null }; + +function pick(obj, ...keys) { + for (const k of keys) { + if (obj && obj[k] != null && obj[k] !== '') return obj[k]; + } + return undefined; +} + +// 현재 워크스페이스에서의 내 역할(콘솔 역할) 이름과, 그 역할에 CSP Role 매핑이 설정된 csp_type 집합. +// 화면에는 콘솔 역할만 보여 준다 — 매핑된 CSP Role 은 역할 관리(Workspaces > Roles)에서 확인한다. +async function fetchCspCredentialAccess(workspaceId) { + const http = webconsolejs['common/api/http']; + const rolesResp = await http.commonAPIPost('/api/mc-iam-manager/GetMyWorkspaceRoles', {}, undefined, QUIET); + const roles = (Array.isArray(rolesResp?.data?.responseData) ? rolesResp.data.responseData : []) + .filter((r) => String(pick(r, 'workspace_id', 'workspaceId')) === String(workspaceId)); + const roleNames = [...new Set(roles.map((r) => String(pick(r, 'role_name', 'roleName') || '')).filter(Boolean))]; + const roleIds = [...new Set(roles.map((r) => pick(r, 'role_id', 'roleId')).filter((id) => id != null))]; + + const cspTypes = new Set(); + for (const roleId of roleIds) { + const resp = await http.commonAPIPost('/api/mc-iam-manager/Getrolescsproles', { + pathParams: { roleId: String(roleId) }, + }, undefined, QUIET); + const cspRoles = resp?.data?.responseData?.cspRoles || resp?.data?.responseData?.csp_roles || []; + (Array.isArray(cspRoles) ? cspRoles : []).forEach((c) => { + const configured = String(pick(c, 'status') || '').toLowerCase() === 'created' + && !!pick(c, 'idp_identifier', 'idpIdentifier') + && !!pick(c, 'iam_identifier', 'iamIdentifier'); + const type = String(pick(c, 'csp_type', 'cspType') || '').toLowerCase(); + if (configured && type) cspTypes.add(type); + }); + } + return { roleNames, cspTypes }; +} + +const EMPTY_ACCESS = () => ({ roleNames: [], cspTypes: new Set() }); + +/** + * 현재 워크스페이스에서의 내 역할 이름과 임시자격증명을 발급받을 수 있는 CSP. + * @returns {Promise<{roleNames: string[], cspTypes: Set}>} cspTypes 는 소문자 csp_type + * 워크스페이스 단위로 1회만 조회해 캐시하고, 워크스페이스가 바뀌면 다시 조회한다. 실패하면 빈 값. + */ +export function getCspCredentialAccess(workspaceId) { + if (!workspaceId) return Promise.resolve(EMPTY_ACCESS()); + if (issuableCache.workspaceId !== String(workspaceId) || !issuableCache.promise) { + issuableCache = { + workspaceId: String(workspaceId), + promise: fetchCspCredentialAccess(workspaceId).catch(() => { + // 실패는 캐시하지 않는다(다음에 열 때 재시도) + issuableCache = { workspaceId: null, promise: null }; + return EMPTY_ACCESS(); + }), + }; + } + return issuableCache.promise; +} + +/** 발급 가능한 CSP 집합 (소문자 csp_type) */ +export async function getIssuableCspTypes(workspaceId) { + return (await getCspCredentialAccess(workspaceId)).cspTypes; +} + +export function clearIssuableCspTypesCache() { + issuableCache = { workspaceId: null, promise: null }; +} diff --git a/front/assets/js/common/api/services/sshkey_api.js b/front/assets/js/common/api/services/sshkey_api.js index 17d994d3..9ede2489 100644 --- a/front/assets/js/common/api/services/sshkey_api.js +++ b/front/assets/js/common/api/services/sshkey_api.js @@ -15,11 +15,12 @@ export async function create(ns, body) { return response?.data; } -export async function get(ns, name) { +// options는 commonAPIPost로 그대로 전달 (예: private key가 콘솔에 남지 않도록 { sensitive: true }) +export async function get(ns, name, options = {}) { const controller = '/api/mc-infra-manager/GetSshKey'; const response = await webconsolejs['common/api/http'].commonAPIPost(controller, { pathParams: { nsId: ns, sshKeyId: name } - }); + }, undefined, options); return response?.data?.responseData; } diff --git a/front/assets/js/pages/operation/manage/infraworkloads.js b/front/assets/js/pages/operation/manage/infraworkloads.js index e864cf40..624ef186 100644 --- a/front/assets/js/pages/operation/manage/infraworkloads.js +++ b/front/assets/js/pages/operation/manage/infraworkloads.js @@ -37,6 +37,8 @@ var currentServerNodeGroupId = ""; var currentServerNodeStatus = ""; // Group 탭 Node Info 에 표시 중인 Node 상태 var currentNodeGroupVmStatus = ""; +// node detail(Detail 탭)의 Keypair 배지가 쓰는 노드 컨텍스트 — 'server'(Node 상세) / 'nodegroup'(NodeGroup 상세) +var nodeDetailContext = { server: null, nodegroup: null }; var currentGroupedVmList = []; var vmListGroupedByNodeGroup = []; @@ -1256,6 +1258,7 @@ export async function vmDetailInfo(vmId) { var vmName = data.name; var vmStatus = data.status; currentServerNodeStatus = vmStatus; + nodeDetailContext.server = buildNodeDetailContext(data); updateInstallSwNodeButton("installsw-node-server-btn", vmStatus); var vmDescription = data.description; var vmPublicIp = data.publicIP == undefined ? "" : data.publicIP; @@ -1488,6 +1491,7 @@ export async function nodeGroup_vmDetailInfo(vmId) { var vmName = data.name; var vmStatus = data.status; currentNodeGroupVmStatus = vmStatus; + nodeDetailContext.nodegroup = buildNodeDetailContext(data); updateInstallSwNodeButton("installsw-node-nodegroup-btn", vmStatus); var vmDescription = data.description; var vmPublicIp = data.publicIP == undefined ? "" : data.publicIP; @@ -3187,11 +3191,57 @@ export async function initMciRemoteCmdModal() { } -export async function getKeypair(el) { - const sshkeyId = el.innerText - $("#keypairModal-bodytitle").text(sshkeyId); - var respSSHkey = await webconsolejs["common/api/services/infra_api"].getsshkey(window.currentNsId, sshkeyId); - $("#keypairModal-textarea").val(respSSHkey.privateKey); +// ─── node detail: Keypair (공통 비밀값 모달 #secretModal) ───────────────── + +function secretModal() { + return webconsolejs["partials/operation/manage/secretmodal"]; +} + +// node 응답에서 Keypair 배지에 필요한 값만 추린다 (비밀값 없음) +// CSP 임시 자격 증명은 노드가 아니라 워크스페이스 역할 기준이라 사용자 메뉴(partials/layout/cspcredential.js)로 옮겼다 +function buildNodeDetailContext(data) { + if (!data) return null; + return { + nodeId: data.id || "", + nodeName: data.name || data.id || "", + sshKeyId: data.sshKeyId || "", + }; +} + +function nodeContextOf(contextType) { + return nodeDetailContext[contextType === "nodegroup" ? "nodegroup" : "server"]; +} + +export async function getKeypair(el, contextType) { + const ctx = nodeContextOf(contextType); + const sshkeyId = (ctx && ctx.sshKeyId) || (el && el.innerText ? el.innerText.trim() : ""); + const title = "Keypair" + (sshkeyId ? " - " + sshkeyId : ""); + const modal = secretModal(); + if (!sshkeyId) { + modal.openLoading(title); + modal.render({ title, emptyText: "No keypair is associated with this node." }); + return; + } + const session = modal.openLoading(title, "Loading keypair..."); + try { + const key = await webconsolejs["common/api/services/sshkey_api"].get( + window.currentNsId, sshkeyId, { sensitive: true } + ); + // 없는 항목도 줄을 남겨 "Not provided"로 보여 준다 + modal.render({ + title, + meta: [{ label: "Keypair Name", value: sshkeyId }], + fields: [ + { label: "Private Key", value: key?.privateKey || key?.privateKeyMaterial || "", kind: "pem" }, + { label: "Public Key", value: key?.publicKey || key?.publicKeyMaterial || "", kind: "plain" }, + { label: "Username", value: key?.username || key?.verifiedUsername || "", kind: "plain" }, + ], + emptyText: "Not available", + }, session); + } catch (err) { + const msg = err?.response?.data?.responseData?.message || err?.message || ""; + modal.renderError(title, "Failed to load the keypair" + (msg ? ": " + msg : "."), {}, session); + } } // Policy 배포 함수 diff --git a/front/assets/js/partials/layout/cspcredential.js b/front/assets/js/partials/layout/cspcredential.js new file mode 100644 index 00000000..a8711cd2 --- /dev/null +++ b/front/assets/js/partials/layout/cspcredential.js @@ -0,0 +1,380 @@ +// Navbar 사용자 메뉴 "CSP Credentials" — CSP 임시 자격 증명(STS) 발급 (전용 모달 #cspCredentialModal) +// +// 발급 근거는 노드가 아니라 "현재 워크스페이스에서의 내 역할 → CSP Role 매핑"이다. +// 그래서 진입점을 노드 상세가 아닌 사용자 메뉴에 두고, 발급 대상 CSP·Region 을 직접 고르게 한다. +// 1단계(request): Workspace(현재 선택, 읽기 전용) / Role(이 워크스페이스에서의 내 콘솔 역할) / CSP(내 역할에 매핑된 CSP만) / Region(해당 CSP 의 connection region) +// 콘솔 안에서는 콘솔 역할만 보여 준다 — 매핑된 CSP Role 은 역할 관리(Workspaces > Roles)에서 확인한다. +// 2단계(result) : 발급 정보 + CLI/API 용 환경변수 블록 하나(기본 가림, Show / Copy) +// 임시 자격 증명은 CLI/API 로 쓰는 것이라 키를 항목별로 나열하지 않는다 +// (SSH 접속 키를 보여 주는 Keypair 모달 #secretModal 과 용도가 달라 모달을 분리했다). +// Region 은 필수다 — 비우면 IAM 의 AWS AssumeRole 이 실패한다(403). +// 발급 값은 이 모듈의 메모리에만 두고 모달이 닫히면 지운다(storage·console 미기록). + +const REQUEST_MODAL_ID = 'cspCredentialModal'; +const DEFAULT_REGION = 'ap-northeast-2'; + +const CSP_CREDENTIAL_SCOPE_NOTICE = + "This credential is issued for the CSP role mapped to your role in the current workspace. " + + "Its permissions follow that CSP role (account-wide)."; + +const MASK = '••••••'; + +// 요청 모달이 연 시점의 상태 — 다시 열면 초기화된다 +let request = { session: 0, workspaceId: '', workspaceName: '', cspTypes: new Set(), regionsByType: null }; + +function $id(id) { + return document.getElementById(id); +} + +function api() { + return webconsolejs['common/api/services/csp_credential_api']; +} + +function requestModalInstance() { + const el = $id(REQUEST_MODAL_ID); + if (!el || typeof bootstrap === 'undefined') return null; + if (!el.dataset.cspCredentialBound) { + el.addEventListener('hidden.bs.modal', clearResult); + el.dataset.cspCredentialBound = 'true'; + } + return bootstrap.Modal.getOrCreateInstance(el); +} + +function setHidden(id, hidden) { + const el = $id(id); + if (el) el.classList.toggle('d-none', !!hidden); +} + +function fillSelect(select, values, selected, labelOf = (v) => v) { + if (!select) return; + select.replaceChildren(); + values.forEach((v) => { + const opt = document.createElement('option'); + opt.value = v; + opt.textContent = labelOf(v); + if (v === selected) opt.selected = true; + select.append(opt); + }); +} + +function showRequestMessage(message) { + const el = $id('cspCredentialModal-message'); + if (el) el.textContent = message || ''; + setHidden('cspCredentialModal-message', !message); +} + +function setFormEnabled(enabled) { + ['cspCredentialModal-csp', 'cspCredentialModal-region', 'cspCredentialModal-issue-btn'].forEach((id) => { + const el = $id(id); + if (el) el.disabled = !enabled; + }); +} + +// ─── Region 후보: tumblebug connection config 의 region (provider 별, 중복 제거) ───────────── + +async function loadRegionsByType() { + if (request.regionsByType) return request.regionsByType; + const byType = new Map(); + try { + const response = await webconsolejs['common/api/http'].commonAPIPost( + '/api/mc-infra-manager/GetConnConfigList', {}, undefined, { loaderType: 'none', silentErrors: true } + ); + const list = response?.data?.responseData?.connectionconfig || []; + list.forEach((c) => { + const type = String(c.providerName || '').toLowerCase(); + const region = c.regionDetail?.regionId || c.regionZoneInfo?.assignedRegion || ''; + if (!type || !region) return; + if (!byType.has(type)) byType.set(type, new Set()); + byType.get(type).add(region); + }); + } catch (e) { + // region 목록을 못 받아도 기본 region 으로 발급은 시도할 수 있다 + } + request.regionsByType = byType; + return byType; +} + +async function updateRegionOptions() { + const csp = $id('cspCredentialModal-csp')?.value || ''; + const byType = await loadRegionsByType(); + const regions = [...(byType.get(csp) || [])].sort(); + if (!regions.length) regions.push(DEFAULT_REGION); + const selected = regions.includes(DEFAULT_REGION) ? DEFAULT_REGION : regions[0]; + fillSelect($id('cspCredentialModal-region'), regions, selected); +} + +// ─── 요청 모달 ─────────────────────────────────────────────────────── + +/** 사용자 메뉴 "CSP Credentials" 클릭 */ +export async function openCspCredentialModal() { + const session = ++request.session; + const workspace = webconsolejs['common/api/services/workspace_api'].getCurrentWorkspace(); + request = { + session, + workspaceId: String(workspace?.id ?? workspace?.Id ?? ''), + workspaceName: workspace?.name || workspace?.Name || '', + cspTypes: new Set(), + regionsByType: null, + }; + + clearResult(); + showStep('request'); + const wsEl = $id('cspCredentialModal-workspace'); + if (wsEl) wsEl.textContent = request.workspaceName || request.workspaceId || '-'; + fillSelect($id('cspCredentialModal-csp'), [], ''); + fillSelect($id('cspCredentialModal-region'), [], ''); + const roleEl = $id('cspCredentialModal-role'); + if (roleEl) roleEl.textContent = '-'; + setFormEnabled(false); + showRequestMessage(''); + setHidden('cspCredentialModal-loading', false); + requestModalInstance()?.show(); + + if (!request.workspaceId) { + setHidden('cspCredentialModal-loading', true); + showRequestMessage('Select a workspace first.'); + return; + } + + const access = await api().getCspCredentialAccess(request.workspaceId); + if (session !== request.session) return; // 그 사이 다시 열렸다 + request.cspTypes = access.cspTypes; + setHidden('cspCredentialModal-loading', true); + if (roleEl) roleEl.textContent = access.roleNames.length ? access.roleNames.join(', ') : '-'; + + const types = [...access.cspTypes].sort(); + if (!types.length) { + showRequestMessage('No CSP role is mapped to your role in this workspace. Ask an administrator.'); + return; + } + fillSelect($id('cspCredentialModal-csp'), types, types[0], (t) => t.toUpperCase()); + await updateRegionOptions(); + if (session !== request.session) return; + setFormEnabled(true); +} + +/** CSP 선택 변경 → Region 후보 갱신 */ +export function onCspChange() { + return updateRegionOptions(); +} + +// ─── 2단계: 발급 결과 (CLI/API 용 환경변수 블록) ────────────────────────── + +// 발급 결과 — 모달이 닫히거나 다시 요청하면 지운다 +let result = { vars: [], visible: false, timer: null, expiration: null }; + +function showStep(step) { + const isResult = step === 'result'; + setHidden('cspCredentialModal-request', isResult); + setHidden('cspCredentialModal-result', !isResult); + setHidden('cspCredentialModal-issue-btn', isResult); + setHidden('cspCredentialModal-back-btn', !isResult); +} + +function clearResult() { + if (result.timer) clearInterval(result.timer); + result = { vars: [], visible: false, timer: null, expiration: null }; + ['cspCredentialModal-env', 'cspCredentialModal-error', 'cspCredentialModal-notice'].forEach((id) => { + const el = $id(id); + if (el) el.textContent = ''; + }); + const meta = $id('cspCredentialModal-meta'); + if (meta) meta.replaceChildren(); + ['cspCredentialModal-issuing', 'cspCredentialModal-error', 'cspCredentialModal-meta', 'cspCredentialModal-credential'] + .forEach((id) => setHidden(id, true)); + const showBtn = $id('cspCredentialModal-show-btn'); + if (showBtn) showBtn.textContent = 'Show'; +} + +// 셸 export 문 — 값은 작은따옴표로 감싸 셸 특수문자를 막는다 +function shellQuote(v) { + return "'" + String(v).replace(/'/g, "'\\''") + "'"; +} + +// CSP 별 CLI/SDK 표준 환경변수 (kind: id=앞뒤 4자 노출, secret=길이만, plain=그대로) +function credentialVars(cred, region) { + const csp = String(cred?.cspType || '').toLowerCase(); + const reg = cred?.region || region || ''; + const v = []; + const add = (name, value, kind) => { if (value) v.push({ name, value: String(value), kind }); }; + switch (csp) { + case 'aws': + add('AWS_ACCESS_KEY_ID', cred.accessKeyId, 'id'); + add('AWS_SECRET_ACCESS_KEY', cred.secretAccessKey, 'secret'); + add('AWS_SESSION_TOKEN', cred.sessionToken, 'secret'); + add('AWS_DEFAULT_REGION', reg, 'plain'); + break; + case 'gcp': + add('GOOGLE_OAUTH_ACCESS_TOKEN', cred.accessToken, 'secret'); + break; + case 'alibaba': + add('ALIBABA_CLOUD_ACCESS_KEY_ID', cred.accessKeyId, 'id'); + add('ALIBABA_CLOUD_ACCESS_KEY_SECRET', cred.accessKeySecret || cred.secretAccessKey, 'secret'); + add('ALIBABA_CLOUD_SECURITY_TOKEN', cred.securityToken || cred.sessionToken, 'secret'); + add('ALIBABA_CLOUD_REGION_ID', reg, 'plain'); + break; + case 'tencent': + add('TENCENTCLOUD_SECRET_ID', cred.accessKeyId, 'id'); + add('TENCENTCLOUD_SECRET_KEY', cred.secretAccessKey, 'secret'); + add('TENCENTCLOUD_SESSION_TOKEN', cred.sessionToken, 'secret'); + add('TENCENTCLOUD_REGION', reg, 'plain'); + break; + default: + // 그 밖의 CSP 는 CLI 표준 변수명이 인증 방식마다 달라 받은 값을 일반 이름으로 내보낸다 + add('CSP_ACCESS_KEY_ID', cred.accessKeyId, 'id'); + add('CSP_SECRET_ACCESS_KEY', cred.secretAccessKey || cred.accessKeySecret, 'secret'); + add('CSP_SESSION_TOKEN', cred.sessionToken || cred.securityToken, 'secret'); + add('CSP_ACCESS_TOKEN', cred.accessToken, 'secret'); + add('CSP_REGION', reg, 'plain'); + } + return v; +} + +function maskVar(item) { + if (item.kind === 'plain') return item.value; + if (item.kind === 'id') { + return item.value.length <= 8 ? '••••••••' : item.value.slice(0, 4) + '••••••••' + item.value.slice(-4); + } + return `${MASK} (${item.value.length} chars)`; +} + +function exportText(vars, masked) { + return vars.map((it) => 'export ' + it.name + '=' + (masked ? maskVar(it) : shellQuote(it.value))).join('\n'); +} + +function renderEnv() { + const pre = $id('cspCredentialModal-env'); + if (pre) { + pre.textContent = exportText(result.vars, !result.visible); + pre.dataset.visible = result.visible ? 'true' : 'false'; + } + const btn = $id('cspCredentialModal-show-btn'); + if (btn) btn.textContent = result.visible ? 'Hide' : 'Show'; +} + +function formatRemaining(expiration) { + const exp = new Date(expiration); + if (isNaN(exp.getTime())) return '-'; + const ms = exp.getTime() - Date.now(); + if (ms <= 0) return 'Expired'; + const totalMin = Math.floor(ms / 60000); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + return (h > 0 ? h + 'h ' : '') + m + 'm remaining'; +} + +function formatExpiration(expiration) { + const exp = new Date(expiration); + if (!expiration || isNaN(exp.getTime()) || exp.getFullYear() < 2000) return ''; + return exp.toLocaleString(); +} + +function renderMeta(items) { + const dl = $id('cspCredentialModal-meta'); + if (!dl) return; + dl.replaceChildren(); + items.forEach((item) => { + const dt = document.createElement('dt'); + dt.className = 'col-sm-3 text-muted fw-normal'; + dt.textContent = item.label; + const dd = document.createElement('dd'); + dd.className = 'col-sm-9 mb-1 text-break'; + dd.id = 'cspCredentialModal-meta-' + item.key; + dd.textContent = item.value || '-'; + dl.append(dt, dd); + }); + setHidden('cspCredentialModal-meta', !items.length); +} + +/** Issue — 2단계로 넘어가 발급 결과(환경변수 블록)를 같은 모달에 표시 */ +export async function issue() { + const csp = $id('cspCredentialModal-csp')?.value || ''; + const region = $id('cspCredentialModal-region')?.value || ''; + if (!request.workspaceId || !csp) return; + const session = request.session; + + clearResult(); + showStep('result'); + setHidden('cspCredentialModal-issuing', false); + const baseMeta = [ + { key: 'csp', label: 'CSP', value: csp.toUpperCase() }, + { key: 'region', label: 'Region', value: region }, + ]; + + let cred; + try { + cred = await api().getTemporaryCredentials({ workspaceId: request.workspaceId, cspType: csp, region }); + } catch (err) { + if (session !== request.session) return; // 그 사이 닫혔다가 다시 열렸다 + setHidden('cspCredentialModal-issuing', true); + renderMeta(baseMeta); + const errEl = $id('cspCredentialModal-error'); + if (errEl) errEl.textContent = err?.message || 'Failed to issue temporary credentials.'; + setHidden('cspCredentialModal-error', false); + return; + } + // 닫힌 뒤 도착한 응답은 버린다(메모리에 남기지 않는다) + const modalEl = $id(REQUEST_MODAL_ID); + if (session !== request.session || !modalEl || !modalEl.classList.contains('show')) return; + setHidden('cspCredentialModal-issuing', true); + + const issuedCsp = String(cred.cspType || csp).toLowerCase(); + const issuedRegion = cred.region || region || ''; + const expirationText = formatExpiration(cred.expiration); + renderMeta([ + { key: 'csp', label: 'CSP', value: issuedCsp.toUpperCase() }, + { key: 'region', label: 'Region', value: issuedRegion }, + { key: 'expiration', label: 'Expiration', value: expirationText || 'Not provided' }, + { key: 'remaining', label: 'Time left', value: expirationText ? formatRemaining(cred.expiration) : '-' }, + ]); + if (expirationText) { + result.expiration = cred.expiration; + result.timer = setInterval(() => { + const el = $id('cspCredentialModal-meta-remaining'); + if (el) el.textContent = formatRemaining(result.expiration); + }, 15000); + } + + result.vars = credentialVars(cred, issuedRegion); + if (!result.vars.length) { + const errEl = $id('cspCredentialModal-error'); + if (errEl) errEl.textContent = 'The response did not contain any credential values.'; + setHidden('cspCredentialModal-error', false); + return; + } + const notice = $id('cspCredentialModal-notice'); + if (notice) notice.textContent = CSP_CREDENTIAL_SCOPE_NOTICE; + renderEnv(); + setHidden('cspCredentialModal-credential', false); +} + +/** Show / Hide — 환경변수 블록 전체 */ +export function toggleShow() { + if (!result.vars.length) return; + result.visible = !result.visible; + renderEnv(); +} + +function flash(btn, label) { + if (!btn) return; + btn.textContent = label; + setTimeout(() => { btn.textContent = 'Copy'; }, 1500); +} + +/** Copy — 표시 여부와 관계없이 실제 값의 export 문을 복사 */ +export function copyCredential() { + const btn = $id('cspCredentialModal-copy-btn'); + if (!result.vars.length || !navigator.clipboard) { + flash(btn, 'Copy failed'); + return Promise.resolve(false); + } + return navigator.clipboard.writeText(exportText(result.vars, false)) + .then(() => { flash(btn, 'Copied!'); return true; }) + .catch(() => { flash(btn, 'Copy failed'); return false; }); +} + +/** Back — 발급 결과를 지우고 1단계로 */ +export function backToRequest() { + clearResult(); + showStep('request'); +} diff --git a/front/assets/js/partials/operation/manage/secretmodal.js b/front/assets/js/partials/operation/manage/secretmodal.js new file mode 100644 index 00000000..e600ab2e --- /dev/null +++ b/front/assets/js/partials/operation/manage/secretmodal.js @@ -0,0 +1,341 @@ +// 공통 비밀값 모달 (#secretModal, templates/partials/operation/manage/_secret_modal.html) +// node detail의 Keypair(private key)와 CSP 임시자격증명 표시에 함께 쓴다. +// +// 보안 원칙 +// - 비밀값은 이 모듈의 메모리(state)에만 둔다. localStorage/sessionStorage/console에 남기지 않는다. +// - 화면에는 기본적으로 가려서 표시하되, 값의 존재·형태는 알 수 있게 한다 +// identifier : 앞 4자 + •••••••• + 뒤 4자 (8자 이하는 전부 가림) — 예: Access Key ID +// secret : •••••• (N chars) — 예: Secret Access Key, Session Token +// pem : 첫 줄(PEM 헤더) + •••••• (N chars), 헤더가 없으면 secret 규칙 — 예: Private Key +// plain : 가리지 않음 — 예: Username, Public Key +// - 항목별 Show/Hide 와 전체 Show all/Hide all. 복사는 표시 여부와 관계없이 원문을 복사한다. +// - 값이 없는 항목은 줄을 남기고 "Not provided" (Copy·Show 비활성). +// - DOM에는 textContent로만 넣는다(innerHTML 금지). 모달이 닫히면 값과 DOM을 모두 비운다. + +const MODAL_ID = 'secretModal'; +const DOTS = '••••••'; +const NOT_PROVIDED = 'Not provided'; +const KINDS = ['identifier', 'secret', 'pem', 'plain']; + +const state = { + session: 0, // 모달을 열 때마다 증가 — 닫힌 뒤 도착한 비동기 응답은 무시한다 + fields: [], // [{ label, value, kind, visible }] + actions: [], // [{ id, label, getText }] + liveTimer: null, + liveItems: [], // [{ el, render }] +}; + +function $id(id) { + return document.getElementById(id); +} + +function modalInstance() { + const el = $id(MODAL_ID); + if (!el || typeof bootstrap === 'undefined') return null; + if (!el.dataset.secretModalBound) { + el.addEventListener('hidden.bs.modal', clearModal); + el.dataset.secretModalBound = 'true'; + } + return bootstrap.Modal.getOrCreateInstance(el); +} + +function setHidden(id, hidden) { + const el = $id(id); + if (el) el.classList.toggle('d-none', !!hidden); +} + +function setText(id, text) { + const el = $id(id); + if (el) el.textContent = text == null ? '' : String(text); +} + +function stopLiveTimer() { + if (state.liveTimer) { + clearInterval(state.liveTimer); + state.liveTimer = null; + } + state.liveItems = []; +} + +// ─── 마스크 ──────────────────────────────────────────────────────────── + +function secretMask(value) { + return `${DOTS} (${value.length} chars)`; +} + +/** 값의 존재·형태만 보이도록 가린 문자열 (원문은 노출하지 않는다) */ +export function maskValue(value, kind) { + const v = String(value ?? ''); + if (!v) return NOT_PROVIDED; + switch (kind) { + case 'plain': + return v; + case 'identifier': + return v.length <= 8 ? '••••••••' : v.slice(0, 4) + '••••••••' + v.slice(-4); + case 'pem': { + const firstLine = v.split(/\r?\n/, 1)[0].trim(); + if (/^-----BEGIN [A-Z0-9 ]+-----$/.test(firstLine)) { + return `${firstLine} ${secretMask(v)}`; + } + return secretMask(v); + } + default: + return secretMask(v); + } +} + +const isMaskable = (f) => !!f.value && f.kind !== 'plain'; + +// 모달 내용·메모리 값을 모두 지운다 (hidden.bs.modal 및 새로 열 때) +export function clearModal() { + state.session += 1; + state.fields = []; + state.actions = []; + stopLiveTimer(); + ['secretModal-fields', 'secretModal-meta'].forEach((id) => { + const el = $id(id); + if (el) el.replaceChildren(); + }); + const actions = $id('secretModal-actions'); + if (actions) { + actions.querySelectorAll('[data-secret-action]').forEach((b) => b.remove()); + } + ['secretModal-error', 'secretModal-notice', 'secretModal-warning'].forEach((id) => setText(id, '')); + ['secretModal-loading', 'secretModal-error', 'secretModal-notice', 'secretModal-warning', + 'secretModal-meta', 'secretModal-toolbar', 'secretModal-empty'].forEach((id) => setHidden(id, true)); + setText('secretModal-toggle-btn', 'Show all'); +} + +/** + * 로딩 상태로 모달을 연다. 반환한 session 값을 render/renderError에 넘기면, + * 그 사이 모달이 닫혔을 때 늦게 도착한 응답이 화면(과 메모리)에 남지 않는다. + */ +export function openLoading(title, loadingText) { + clearModal(); + setText('secretModal-title', title || 'Secret'); + setText('secretModal-loading-text', loadingText || 'Loading...'); + setHidden('secretModal-loading', false); + const inst = modalInstance(); + if (inst) inst.show(); + return state.session; +} + +function isStale(session) { + return session !== undefined && session !== state.session; +} + +function renderMeta(meta) { + const dl = $id('secretModal-meta'); + if (!dl) return; + dl.replaceChildren(); + (meta || []).forEach((item, i) => { + if (!item) return; + const dt = document.createElement('dt'); + dt.className = 'col-sm-4 text-muted fw-normal'; + dt.textContent = item.label; + const dd = document.createElement('dd'); + dd.className = 'col-sm-8 mb-1 text-break'; + dd.id = 'secretModal-meta-' + (item.key || i); + if (typeof item.render === 'function') { + dd.textContent = item.render(); + state.liveItems.push({ el: dd, render: item.render }); + } else { + dd.textContent = item.value == null || item.value === '' ? '-' : String(item.value); + } + dl.append(dt, dd); + }); + setHidden('secretModal-meta', !(meta && meta.length)); + if (state.liveItems.length) { + state.liveTimer = setInterval(() => { + state.liveItems.forEach(({ el, render }) => { el.textContent = render(); }); + }, 15000); + } +} + +function smallButton(id, label, onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm btn-outline-primary'; + btn.id = id; + btn.textContent = label; + btn.addEventListener('click', onClick); + return btn; +} + +function renderFields() { + const box = $id('secretModal-fields'); + if (!box) return; + box.replaceChildren(); + state.fields.forEach((field, i) => { + const wrap = document.createElement('div'); + wrap.className = 'mb-3'; + wrap.id = 'secretModal-field-' + i; + + const head = document.createElement('div'); + head.className = 'd-flex align-items-center gap-2 mb-1'; + const label = document.createElement('label'); + label.className = 'form-label mb-0 me-auto'; + label.textContent = field.label; + head.append(label); + + if (field.kind !== 'plain') { + const showBtn = smallButton('secretModal-show-' + i, 'Show', () => toggleField(i)); + showBtn.classList.replace('btn-outline-primary', 'btn-outline-secondary'); + showBtn.disabled = !field.value; + head.append(showBtn); + } + const copyBtn = smallButton('secretModal-copy-' + i, 'Copy', () => copyField(i)); + copyBtn.disabled = !field.value; + head.append(copyBtn); + + const pre = document.createElement('pre'); + pre.id = 'secretModal-value-' + i; + // Tabler 기본 pre 는 어두운 배경·밝은 글자라 bg-light 만 주면 글자가 안 보인다 — 글자색도 함께 지정 + pre.className = 'bg-light p-2 rounded small mb-0 ' + (field.value ? 'text-dark' : 'text-muted fst-italic'); + pre.style.whiteSpace = 'pre-wrap'; + pre.style.wordBreak = 'break-all'; + pre.style.maxHeight = '320px'; + pre.style.overflowY = 'auto'; + pre.dataset.kind = field.kind; + pre.dataset.provided = field.value ? 'true' : 'false'; + + wrap.append(head, pre); + box.append(wrap); + }); + renderVisibility(); +} + +function renderVisibility() { + state.fields.forEach((field, i) => { + const pre = $id('secretModal-value-' + i); + if (pre) { + pre.textContent = field.value && field.visible ? field.value : maskValue(field.value, field.kind); + pre.dataset.visible = field.value && field.visible ? 'true' : 'false'; + } + const btn = $id('secretModal-show-' + i); + if (btn) btn.textContent = field.visible ? 'Hide' : 'Show'; + }); + const maskable = state.fields.filter(isMaskable); + setHidden('secretModal-toggle-btn', maskable.length === 0); + setText('secretModal-toggle-btn', maskable.length && maskable.every((f) => f.visible) ? 'Hide all' : 'Show all'); +} + +function renderActions() { + const box = $id('secretModal-actions'); + if (!box) return; + const toggle = $id('secretModal-toggle-btn'); + state.actions.forEach((action, i) => { + const btn = smallButton('secretModal-action-' + (action.id || i), action.label, () => runAction(i)); + btn.dataset.secretAction = 'true'; + box.insertBefore(btn, toggle); + }); +} + +/** + * 모달 내용을 그린다. + * @param {object} opts + * @param {string} opts.title + * @param {Array<{label:string,value?:string,render?:Function,key?:string}>} [opts.meta] + * @param {string} [opts.notice] 정보 안내(alert-info) + * @param {string} [opts.warning] 주의(alert-warning) + * @param {Array<{label:string,value?:string,kind?:'identifier'|'secret'|'pem'|'plain'}>} [opts.fields] + * 표시할 항목(기대 필드). 값이 비면 "Not provided" 행으로 남는다. + * @param {Array<{id:string,label:string,getText:Function}>} [opts.actions] 복사 액션 (예: 환경변수 형식) + * @param {string} [opts.emptyText] 항목이 하나도 없을 때 문구 (기본 "Not available") + * @param {number} [session] openLoading이 돌려준 값 + */ +export function render(opts = {}, session) { + if (isStale(session)) return false; + setHidden('secretModal-loading', true); + setText('secretModal-title', opts.title || 'Secret'); + + state.fields = (opts.fields || []) + .filter((f) => f && f.label) + .map((f) => ({ + label: f.label, + value: f.value == null ? '' : String(f.value), + kind: KINDS.includes(f.kind) ? f.kind : 'secret', + visible: false, + })); + state.actions = (opts.actions || []).filter(Boolean); + + renderMeta(opts.meta); + if (opts.notice) { setText('secretModal-notice', opts.notice); setHidden('secretModal-notice', false); } + if (opts.warning) { setText('secretModal-warning', opts.warning); setHidden('secretModal-warning', false); } + + const hasFields = state.fields.length > 0; + const hasValue = state.fields.some((f) => f.value); + setText('secretModal-empty', opts.emptyText || 'Not available'); + setHidden('secretModal-empty', hasFields); + setHidden('secretModal-toolbar', !hasFields); + renderFields(); + if (hasValue) renderActions(); + + const inst = modalInstance(); + if (inst) inst.show(); + return true; +} + +export function renderError(title, message, { meta, notice } = {}, session) { + if (isStale(session)) return false; + setHidden('secretModal-loading', true); + setText('secretModal-title', title || 'Secret'); + renderMeta(meta); + if (notice) { setText('secretModal-notice', notice); setHidden('secretModal-notice', false); } + setText('secretModal-error', message || 'An error occurred.'); + setHidden('secretModal-error', false); + const inst = modalInstance(); + if (inst) inst.show(); + return true; +} + +// 항목 하나의 표시 상태를 바꾼다 +export function toggleField(index) { + const field = state.fields[index]; + if (!field || !isMaskable(field)) return; + field.visible = !field.visible; + renderVisibility(); +} + +// Show all / Hide all — 하나라도 가려져 있으면 전부 보이기, 모두 보이면 전부 가리기 +export function toggleSecrets() { + const maskable = state.fields.filter(isMaskable); + const showAll = !maskable.every((f) => f.visible); + maskable.forEach((f) => { f.visible = showAll; }); + renderVisibility(); +} + +function flashButton(btn, label, original) { + if (!btn) return; + btn.textContent = label; + setTimeout(() => { btn.textContent = original; }, 1500); +} + +function writeClipboard(text, btn, original) { + if (!text || !navigator.clipboard) { + flashButton(btn, 'Copy failed', original); + return Promise.resolve(false); + } + return navigator.clipboard.writeText(text) + .then(() => { flashButton(btn, 'Copied!', original); return true; }) + .catch(() => { flashButton(btn, 'Copy failed', original); return false; }); +} + +// 화면 표시 여부와 관계없이 원문을 복사한다 +export function copyField(index) { + const field = state.fields[index]; + if (!field || !field.value) return Promise.resolve(false); + return writeClipboard(field.value, $id('secretModal-copy-' + index), 'Copy'); +} + +export function runAction(index) { + const action = state.actions[index]; + if (!action) return Promise.resolve(false); + let text = ''; + try { + text = action.getText(state.fields) || ''; + } catch (e) { + text = ''; + } + return writeClipboard(text, $id('secretModal-action-' + (action.id || index)), action.label); +} diff --git a/front/templates/pages/operations/manage/workloads/infraworkloads.html b/front/templates/pages/operations/manage/workloads/infraworkloads.html index 966a74fe..7ae0a247 100644 --- a/front/templates/pages/operations/manage/workloads/infraworkloads.html +++ b/front/templates/pages/operations/manage/workloads/infraworkloads.html @@ -1418,6 +1418,8 @@

Drop Down Files

{{ partial("partials/operation/manage/cspspeclist.html") | raw }} {{ partial("partials/operation/manage/installsw_modal.html") | raw }} + +{{ partial("partials/operation/manage/secret_modal.html") | raw }} @@ -109,6 +112,10 @@

Requests

{{ javascriptTag("partials/layout/navbar.js") | raw }} {{ javascriptTag("common/api/services/workspace_api.js") | raw }} {{ javascriptTag("pages/auth/changepassword.js") | raw }} + +{{ partial("partials/layout/csp_credential_modal.html") | raw }} +{{ javascriptTag("common/api/services/csp_credential_api.js") | raw }} +{{ javascriptTag("partials/layout/cspcredential.js") | raw }}
Keypair Name
-
@@ -104,21 +104,3 @@
- - \ No newline at end of file diff --git a/front/templates/partials/operation/manage/_secret_modal.html b/front/templates/partials/operation/manage/_secret_modal.html new file mode 100644 index 00000000..310aec91 --- /dev/null +++ b/front/templates/partials/operation/manage/_secret_modal.html @@ -0,0 +1,34 @@ + + + diff --git a/front/templates/partials/operation/manage/_server_detail.html b/front/templates/partials/operation/manage/_server_detail.html index d208c153..34bf1df6 100644 --- a/front/templates/partials/operation/manage/_server_detail.html +++ b/front/templates/partials/operation/manage/_server_detail.html @@ -87,9 +87,9 @@
Keypair Name
-
@@ -104,21 +104,3 @@
- - \ No newline at end of file