From d09abab5a2b36224a8939410e8708033eb9b5399 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 3 Feb 2026 14:21:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=203=EB=8B=A8=EA=B3=84=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=ED=99=95=EC=9E=A5,=20keto=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=95=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 2 + .../src/components/layout/RoleSwitcher.tsx | 68 ++++++ .../tenants/routes/TenantListPage.tsx | 13 +- .../tenants/routes/TenantProfilePage.tsx | 33 ++- adminfront/src/lib/adminApi.ts | 7 + adminfront/src/lib/apiClient.ts | 6 + backend/cmd/keto_test/main.go | 42 ++++ backend/cmd/keygen/main.go | 80 +++++++ backend/cmd/server/main.go | 67 ++++-- backend/go.mod | 4 +- backend/internal/bootstrap/tenant_seed.go | 6 +- backend/internal/domain/auth_models.go | 20 +- backend/internal/domain/tenant.go | 14 +- backend/internal/domain/user.go | 11 +- backend/internal/handler/auth_handler.go | 211 ++++++++++-------- backend/internal/handler/tenant_handler.go | 41 ++++ backend/internal/handler/user_handler.go | 101 ++++++++- backend/internal/middleware/rbac.go | 140 ++++++++++++ .../internal/repository/tenant_repository.go | 14 ++ backend/internal/service/keto_service.go | 131 +++++++++++ backend/internal/service/tenant_service.go | 94 +++++++- backend/internal/utils/slug.go | 52 +++++ docker/ory/keto/keto.yml | 2 +- docker/ory/keto/namespaces.ts | 53 +++++ 24 files changed, 1071 insertions(+), 141 deletions(-) create mode 100644 adminfront/src/components/layout/RoleSwitcher.tsx create mode 100644 backend/cmd/keto_test/main.go create mode 100644 backend/cmd/keygen/main.go create mode 100644 backend/internal/middleware/rbac.go create mode 100644 backend/internal/service/keto_service.go create mode 100644 backend/internal/utils/slug.go create mode 100644 docker/ory/keto/namespaces.ts diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index c7e651a7..03f37346 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; +import RoleSwitcher from "./RoleSwitcher"; const navItems = [ { label: "Overview", to: "/", icon: LayoutDashboard }, @@ -132,6 +133,7 @@ function AppLayout() {
+ ); diff --git a/adminfront/src/components/layout/RoleSwitcher.tsx b/adminfront/src/components/layout/RoleSwitcher.tsx new file mode 100644 index 00000000..c2036d7f --- /dev/null +++ b/adminfront/src/components/layout/RoleSwitcher.tsx @@ -0,0 +1,68 @@ +import React, { useState, useEffect } from 'react'; + +const RoleSwitcher: React.FC = () => { + const [currentRole, setCurrentRole] = useState('super_admin'); + + useEffect(() => { + // localStorage에서 역할 읽기 + const savedRole = window.localStorage.getItem('X-Mock-Role'); + if (savedRole) { + setCurrentRole(savedRole); + } else { + // 기본값 설정 + window.localStorage.setItem('X-Mock-Role', 'super_admin'); + } + }, []); + + const switchRole = (role: string) => { + // localStorage 설정 + window.localStorage.setItem('X-Mock-Role', role); + setCurrentRole(role); + // 페이지 새로고침하여 권한 적용 + window.location.reload(); + }; + + if (process.env.NODE_ENV === 'production') return null; + + return ( +
+
+ 🛠 DEV Role Switcher +
+ {(['super_admin', 'tenant_admin', 'user'] as const).map(role => ( + + ))} +
+ ); +}; + +export default RoleSwitcher; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 92438a11..776108f4 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -127,7 +127,18 @@ function TenantListPage() { {tenant.slug} {tenant.status} diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 9850b814..f623944e 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Save, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -15,6 +15,7 @@ import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { Textarea } from "../../../components/ui/textarea"; import { + approveTenant, deleteTenant, fetchTenant, updateTenant, @@ -23,6 +24,7 @@ import { export function TenantProfilePage() { const { tenantId } = useParams<{ tenantId: string }>(); const navigate = useNavigate(); + const queryClient = useQueryClient(); if (!tenantId) { return
Tenant ID is missing
; @@ -62,7 +64,18 @@ export function TenantProfilePage() { .filter((d) => d !== ""), }), onSuccess: () => { - navigate("/tenants"); + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + alert("Tenant updated successfully"); + }, + }); + + const approveMutation = useMutation({ + mutationFn: () => approveTenant(tenantId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + alert("Tenant approved successfully"); }, }); @@ -84,6 +97,12 @@ export function TenantProfilePage() { } }; + const handleApprove = () => { + if (window.confirm("Approve this tenant?")) { + approveMutation.mutate(); + } + }; + return ( <> @@ -168,6 +187,16 @@ export function TenantProfilePage() { Delete
+ {status === "pending" && ( + + )} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index ddd96095..7ae1a82a 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -132,6 +132,13 @@ export async function deleteTenant(tenantId: string) { await apiClient.delete(`/v1/admin/tenants/${tenantId}`); } +export async function approveTenant(tenantId: string) { + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/approve`, + ); + return data; +} + // API Key Management (M2M) export type ApiKeyCreateRequest = { name: string; diff --git a/adminfront/src/lib/apiClient.ts b/adminfront/src/lib/apiClient.ts index 59ea9cc3..c93d5766 100644 --- a/adminfront/src/lib/apiClient.ts +++ b/adminfront/src/lib/apiClient.ts @@ -17,6 +17,12 @@ apiClient.interceptors.request.use((config) => { config.headers["X-Tenant-ID"] = tenantId; } + // [Development Only] Inject Mock Role from RoleSwitcher + const mockRole = window.localStorage.getItem("X-Mock-Role"); + if (mockRole) { + config.headers["X-Test-Role"] = mockRole; + } + return config; }); diff --git a/backend/cmd/keto_test/main.go b/backend/cmd/keto_test/main.go new file mode 100644 index 00000000..82cc33fc --- /dev/null +++ b/backend/cmd/keto_test/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "baron-sso-backend/internal/service" + "context" + "fmt" + "os" +) + +func main() { + // KetoService 초기화 + // KETO_READ_URL과 KETO_WRITE_URL은 컨테이너 외부 포트 또는 내부 주소에 맞게 설정 필요 + os.Setenv("KETO_READ_URL", "http://keto:4466") + os.Setenv("KETO_WRITE_URL", "http://keto:4467") + +keto := service.NewKetoService() + ctx := context.Background() + + userID := "test-user-id" + tenantID := "test-tenant-id" + + fmt.Println("--- Keto ReBAC Test Start ---") + + // 1. 초기 권한 체크 (당연히 거부되어야 함) + allowed, _ := keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view") + fmt.Printf("1. Initial Check (view): %v (Expected: false)\n", allowed) + + // 2. 관계(Relation) 추가 + fmt.Println("2. Adding relation: User is member of Tenant...") + err := keto.CreateRelation(ctx, "Tenant", tenantID, "members", userID) + if err != nil { + fmt.Printf("Failed to create relation: %v\n", err) + return + } + + // 3. 다시 권한 체크 (허용되어야 함) + // OPL 정의에 의해 members는 view 권한을 포함함 + allowed, _ = keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view") + fmt.Printf("3. Final Check (view): %v (Expected: true)\n", allowed) + + fmt.Println("--- Test Completed ---") +} diff --git a/backend/cmd/keygen/main.go b/backend/cmd/keygen/main.go new file mode 100644 index 00000000..8fc8b558 --- /dev/null +++ b/backend/cmd/keygen/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "os" + + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type ApiKey struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + Name string + ClientID string `gorm:"uniqueIndex"` + ClientSecretHash string + Scopes string + Status string `gorm:"default:'active'"` +} + +func generateToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func main() { + godotenv.Load(".env") + godotenv.Load("backend/.env") + + pgHost := os.Getenv("DB_HOST") + if pgHost == "" { pgHost = "localhost" } + pgPort := os.Getenv("DB_PORT") + if pgPort == "" { pgPort = "5432" } + pgUser := os.Getenv("DB_USER") + if pgUser == "" { pgUser = "baron" } + pgPass := os.Getenv("DB_PASSWORD") + if pgPass == "" { pgPass = "password" } + pgName := os.Getenv("DB_NAME") + if pgName == "" { pgName = "baron_sso" } + + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + pgHost, pgUser, pgPass, pgName, pgPort) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to connect to DB: %v", err) + } + + clientID := generateToken(8) + plainSecret := generateToken(16) + hashedSecret, _ := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) + + key := ApiKey{ + Name: "Test Admin Key", + ClientID: clientID, + ClientSecretHash: string(hashedSecret), + Scopes: "tenant:read tenant:write user:read user:write audit:read audit:write", + Status: "active", + } + + if err := db.Table("api_keys").Create(&key).Error; err != nil { + log.Fatalf("Failed to create API key: %v", err) + } + + fmt.Println("====================================================") + fmt.Println("✅ API Key Generated Successfully!") + fmt.Printf("Client ID: %s\n", clientID) + fmt.Printf("Client Secret: %s\n", plainSecret) + fmt.Println("====================================================") + fmt.Println("Usage Example:") + fmt.Printf("curl -H \"X-Baron-Key-ID: %s\" -H \"X-Baron-Key-Secret: %s\" http://localhost:3000/api/v1/admin/tenants\n", clientID, plainSecret) + fmt.Println("====================================================") +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 61ef792f..452f3778 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -200,6 +200,8 @@ func main() { slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) } + ketoService := service.NewKetoService() + // Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다. var oathkeeperProbe *HTTPProbe if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" { @@ -225,16 +227,17 @@ func main() { // 2. Initialize Handlers tenantRepo := repository.NewTenantRepository(db) tenantService := service.NewTenantService(tenantRepo) + tenantService.SetKetoService(ketoService) // Keto 주입 userRepo := repository.NewUserRepository(db) auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo) + authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, ketoService, userRepo) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler(redisService) tenantHandler := handler.NewTenantHandler(db, tenantService) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() - userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, userRepo) + userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber @@ -452,6 +455,9 @@ func main() { api.Get("/audit", auditHandler.ListLogs) api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline) + // Public Tenant Registration + api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) + // Auth Proxy Routes auth := api.Group("/auth") auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) @@ -463,6 +469,15 @@ func main() { auth.Get("/consent", authHandler.GetConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest) + auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) + auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) + auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) + auth.Post("/login/code/verify", authHandler.VerifyLoginCode) + auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) + auth.Post("/password/login", authHandler.PasswordLogin) + auth.Get("/consent", authHandler.GetConsentRequest) + auth.Post("/consent/accept", authHandler.AcceptConsentRequest) + auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) @@ -496,25 +511,41 @@ func main() { // Admin Routes admin := api.Group("/admin") admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가 - admin.Get("/check", adminHandler.CheckAuth) - admin.Get("/stats", adminHandler.GetSystemStats) - admin.Get("/tenants", tenantHandler.ListTenants) - admin.Post("/tenants", tenantHandler.CreateTenant) - admin.Get("/tenants/:id", tenantHandler.GetTenant) - admin.Put("/tenants/:id", tenantHandler.UpdateTenant) - admin.Delete("/tenants/:id", tenantHandler.DeleteTenant) + + // RBAC Middleware Instances + requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{ + AllowedRoles: []string{domain.RoleSuperAdmin}, + AuthHandler: authHandler, + KetoService: ketoService, + }) + requireAdmin := middleware.RequireRole(middleware.RBACConfig{ + AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, + AuthHandler: authHandler, + KetoService: ketoService, + }) + + admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) + admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) + + // Tenant Management (Super Admin Only) + admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants) + admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant) + admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant) + admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant) + admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant) + admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant) // Admin User Management - admin.Get("/users", userHandler.ListUsers) - admin.Post("/users", userHandler.CreateUser) - admin.Get("/users/:id", userHandler.GetUser) - admin.Put("/users/:id", userHandler.UpdateUser) - admin.Delete("/users/:id", userHandler.DeleteUser) + admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요 + admin.Post("/users", requireAdmin, userHandler.CreateUser) + admin.Get("/users/:id", requireAdmin, userHandler.GetUser) + admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser) + admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser) - // API Key Management (M2M) - admin.Get("/api-keys", apiKeyHandler.ListApiKeys) - admin.Post("/api-keys", apiKeyHandler.CreateApiKey) - admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey) + // API Key Management (M2M) - Super Admin Only + admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys) + admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey) + admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정) dev := api.Group("/dev") diff --git a/backend/go.mod b/backend/go.mod index 2a346b53..a7d03551 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.7 github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 github.com/bwmarrin/snowflake v0.3.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/descope/go-sdk v1.7.0 github.com/go-redis/redis/v8 v8.11.5 github.com/gofiber/fiber/v2 v2.52.10 @@ -16,6 +17,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.46.0 + golang.org/x/oauth2 v0.34.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -36,7 +38,6 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-faster/city v1.0.1 // indirect @@ -74,7 +75,6 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect - golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index 394576ba..26e5d98f 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -1,6 +1,7 @@ package bootstrap import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" @@ -56,11 +57,14 @@ func SeedTenants(db *gorm.DB) error { } slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) - _, err = svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains) + tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains) if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err } + // Explicitly set to active during seed + tenant.Status = domain.TenantStatusActive + db.Save(tenant) } return nil } diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index a0cacfcd..6814090e 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -54,14 +54,15 @@ type VerifySignupCodeRequest struct { } type SignupRequest struct { - Email string `json:"email"` - Password string `json:"password"` - Name string `json:"name"` - Phone string `json:"phone"` - AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL" - CompanyCode string `json:"companyCode,omitempty"` - Department string `json:"department"` - TermsAccepted bool `json:"termsAccepted"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL" + CompanyCode string `json:"companyCode,omitempty"` + Department string `json:"department"` + Metadata JSONMap `json:"metadata,omitempty"` + TermsAccepted bool `json:"termsAccepted"` } // User Profile Models @@ -71,9 +72,12 @@ type UserProfileResponse struct { Email string `json:"email"` Name string `json:"name"` Phone string `json:"phone"` + Role string `json:"role"` // 추가 Department string `json:"department"` AffiliationType string `json:"affiliationType"` CompanyCode string `json:"companyCode,omitempty"` + TenantID *string `json:"tenantId,omitempty"` // 추가 + RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 Metadata map[string]any `json:"metadata,omitempty"` Tenant *Tenant `json:"tenant,omitempty"` } diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index d0d2e13a..79fe1158 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -7,13 +7,21 @@ import ( "gorm.io/gorm" ) +// Tenant statuses +const ( + TenantStatusPending = "pending" + TenantStatusActive = "active" + TenantStatusSuspended = "suspended" + TenantStatusDeleted = "deleted" +) + // Tenant represents a tenant model stored in PostgreSQL. type Tenant struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` Name string `gorm:"not null" json:"name"` Slug string `gorm:"uniqueIndex;not null" json:"slug"` Description string `json:"description"` - Status string `gorm:"default:'active'" json:"status"` + Status string `gorm:"default:'pending'" json:"status"` Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"` Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"` CreatedAt time.Time `json:"createdAt"` @@ -21,6 +29,10 @@ type Tenant struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +func (t *Tenant) IsActive() bool { + return t.Status == TenantStatusActive +} + // BeforeCreate hook to generate UUID if not present. func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) { if t.ID == "" { diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 307fea35..c26f352e 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -7,6 +7,14 @@ import ( "gorm.io/gorm" ) +// User roles +const ( + RoleSuperAdmin = "super_admin" // 시스템 전역 관리자 + RoleTenantAdmin = "tenant_admin" // 테넌트 관리자 + RoleRPAdmin = "rp_admin" // 특정 앱(RP) 관리자 + RoleUser = "user" // 일반 사용자 +) + // User represents the user model stored in PostgreSQL type User struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` @@ -14,11 +22,12 @@ type User struct { PasswordHash string `gorm:"not null" json:"-"` Name string `gorm:"not null" json:"name"` Phone string `json:"phone"` - Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user' + Role string `gorm:"default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user AffiliationType string `json:"affiliationType"` CompanyCode string `json:"companyCode"` TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` + RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 Department string `json:"department"` Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"` Status string `gorm:"default:'active'" json:"status"` diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2eacc8b3..0a3934cc 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -90,6 +90,7 @@ type AuthHandler struct { AuditRepo domain.AuditRepository Hydra *service.HydraAdminService TenantService service.TenantService + KetoService service.KetoService UserRepo repository.UserRepository } @@ -148,7 +149,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du return false, int(interval.Seconds()) } -func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler { +func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -175,6 +176,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident AuditRepo: auditRepo, Hydra: service.NewHydraAdminService(), TenantService: tenantService, + KetoService: ketoService, UserRepo: userRepo, } } @@ -403,16 +405,26 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) } - // [New] Auto-Assign Tenant by Domain - companyCode := req.CompanyCode - if companyCode == "" { - parts := strings.Split(req.Email, "@") - if len(parts) == 2 { - domainName := parts[1] - tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName) - if err == nil && tenant != nil { + // [Strict] Enforce Tenant Auto-Assignment by Domain ONLY + // Manual companyCode from request is ignored to prevent unauthorized tenant joining + companyCode := "" + var tenantID *string + + parts := strings.Split(req.Email, "@") + if len(parts) == 2 { + domainName := parts[1] + tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName) + if err == nil && tenant != nil { + if tenant.Status == domain.TenantStatusActive { slog.Info("[Signup] Auto-assigning tenant", "email", req.Email, "tenant", tenant.Slug) companyCode = tenant.Slug + tenantID = &tenant.ID + } else { + slog.Warn("[Signup] Attempted to join non-active tenant", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) + // Policy: If tenant exists but not active, reject signup or allow as general? + // For now, let's allow as general but log it. + // Or return error if we want strict domain locking. + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Your organization's tenant is currently not active."}) } } } @@ -467,21 +479,27 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { Phone: normalizedPhone, AffiliationType: req.AffiliationType, CompanyCode: companyCode, + TenantID: tenantID, Department: req.Department, Role: "user", Status: "active", + Metadata: req.Metadata, } - // Link TenantID if possible - if companyCode != "" { - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), companyCode); err == nil && tenant != nil { - localUser.TenantID = &tenant.ID + if h.UserRepo != nil { + if err := h.UserRepo.Create(c.Context(), localUser); err != nil { + slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err) } } - if err := h.UserRepo.Create(c.Context(), localUser); err != nil { - slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err) - // We don't fail the whole signup if local sync fails + // [Keto] Sync user-tenant relationship + if h.KetoService != nil && tenantID != nil { + go func() { + err := h.KetoService.CreateRelation(context.Background(), "Tenant", *tenantID, "members", providerID) + if err != nil { + slog.Error("[Signup] Failed to sync membership to Keto", "userID", providerID, "tenantID", *tenantID, "error", err) + } + }() } return c.JSON(fiber.Map{ @@ -2312,61 +2330,20 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { return phone } -// GetMe - Returns current user's profile with 010 phone format +// GetMe - Returns current user's profile with enriched data from local DB func (h *AuthHandler) GetMe(c *fiber.Ctx) error { - token := h.getBearerToken(c) - if token != "" { - if looksLikeJWT(token) && h.DescopeClient != nil { - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err == nil && authorized { - userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) - } - - dept, _ := userResponse.CustomAttributes["department"].(string) - affType, _ := userResponse.CustomAttributes["affiliationType"].(string) - compCode, _ := userResponse.CustomAttributes["companyCode"].(string) - - resp := domain.UserProfileResponse{ - ID: userResponse.UserID, - Email: userResponse.Email, - Name: userResponse.Name, - Phone: h.formatPhoneForDisplay(userResponse.Phone), - Department: dept, - AffiliationType: affType, - CompanyCode: compCode, - Metadata: userResponse.CustomAttributes, - } - - if compCode != "" { - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode); err == nil && tenant != nil { - resp.Tenant = tenant - } - } - - return c.JSON(resp) - } - } - - profile, err := h.getKratosProfile(token) - if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) - } - return c.JSON(profile) - } - - cookie := c.Get("Cookie") - if cookie == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) - } - profile, err := h.getKratosProfileWithCookie(cookie) + profile, err := h.resolveCurrentProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(profile) } +// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares +func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { + return h.resolveCurrentProfile(c) +} + func looksLikeJWT(token string) bool { return strings.Count(token, ".") == 2 } @@ -2955,44 +2932,96 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { return c.JSON(acceptResp) } - func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { + // [Development Mode Fallback] + if os.Getenv("APP_ENV") != "production" { + // 우선순위: 1. 헤더, 2. 쿠키, 3. 기본값(user) + testRole := c.Get("X-Test-Role") + if testRole == "" { + testRole = c.Cookies("X-Mock-Role") + } + + if testRole == "" { + testRole = domain.RoleUser // 기본값을 user로 변경하여 차단 확인 + } + + slog.Info("Using MOCK profile", "role", testRole, "source", "dev_fallback") + return &domain.UserProfileResponse{ + ID: "dev-admin-uuid", + Email: "dev-admin@baron.local", + Name: "Dev Admin (" + testRole + ")", + Role: testRole, + CompanyCode: "hanmac", + }, nil + } + + var profile *domain.UserProfileResponse + var err error + token := h.getBearerToken(c) if token != "" { if looksLikeJWT(token) && h.DescopeClient != nil { - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err == nil && authorized { - userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) - if err != nil { - return nil, err + authorized, userToken, authErr := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if authErr == nil && authorized { + userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if loadErr == nil { + dept, _ := userResponse.CustomAttributes["department"].(string) + affType, _ := userResponse.CustomAttributes["affiliationType"].(string) + compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + + profile = &domain.UserProfileResponse{ + ID: userResponse.UserID, + Email: userResponse.Email, + Name: userResponse.Name, + Phone: h.formatPhoneForDisplay(userResponse.Phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + Metadata: userResponse.CustomAttributes, + } } - dept, _ := userResponse.CustomAttributes["department"].(string) - affType, _ := userResponse.CustomAttributes["affiliationType"].(string) - compCode, _ := userResponse.CustomAttributes["companyCode"].(string) - return &domain.UserProfileResponse{ - ID: userResponse.UserID, - Email: userResponse.Email, - Name: userResponse.Name, - Phone: h.formatPhoneForDisplay(userResponse.Phone), - Department: dept, - AffiliationType: affType, - CompanyCode: compCode, - }, nil } } - profile, err := h.getKratosProfile(token) - if err != nil { - return nil, err + if profile == nil { + profile, err = h.getKratosProfile(token) + } + } else { + cookie := c.Get("Cookie") + if cookie != "" { + profile, err = h.getKratosProfileWithCookie(cookie) } - return profile, nil } - cookie := c.Get("Cookie") - if cookie == "" { - return nil, fmt.Errorf("missing authorization token") + if err != nil || profile == nil { + return nil, errors.New("invalid session") } - return h.getKratosProfileWithCookie(cookie) + + // [New] Enrich with Local DB (Roles, TenantID, etc.) + if h.UserRepo != nil { + localUser, err := h.UserRepo.FindByID(c.Context(), profile.ID) + if err == nil && localUser != nil { + profile.Role = localUser.Role + profile.TenantID = localUser.TenantID + profile.RelyingPartyID = localUser.RelyingPartyID + if profile.Tenant == nil && localUser.Tenant != nil { + profile.Tenant = localUser.Tenant + } + // 병합되지 않은 메타데이터 처리 (필요시) + } else { + // 로컬 DB에 없으면 기본 권한 부여 (또는 강제 생성 정책) + profile.Role = domain.RoleUser + } + } + + // 로컬 DB에 Tenant 정보가 없더라도 companyCode(slug)가 있으면 조회 시도 + if profile.Tenant == nil && profile.CompanyCode != "" { + if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil { + profile.Tenant = tenant + } + } + + return profile, nil } func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index affed786..15558be5 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -39,6 +39,47 @@ type tenantListResponse struct { Total int64 `json:"total"` } +func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { + var req struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Domain string `json:"domain"` + AdminEmail string `json:"adminEmail"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Basic validation + if req.Name == "" || req.Domain == "" || req.AdminEmail == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name, domain, and adminEmail are required"}) + } + + tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ + "message": "Registration request received and is pending approval.", + "tenant": mapTenantSummary(*tenant), + }) +} + +func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { + tenantID := c.Params("id") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + } + + if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"message": "Tenant approved successfully"}) +} + func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { if h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 8c37408c..6166ef5b 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -17,14 +17,16 @@ type UserHandler struct { KratosAdmin *service.KratosAdminService OryProvider *service.OryProvider TenantService service.TenantService + KetoService service.KetoService UserRepo repository.UserRepository } -func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, userRepo repository.UserRepository) *UserHandler { +func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *UserHandler { return &UserHandler{ KratosAdmin: kratosAdmin, OryProvider: oryProvider, TenantService: tenantService, + KetoService: ketoService, UserRepo: userRepo, } } @@ -57,6 +59,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) } + // [New] Get requester profile from middleware + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) search := strings.TrimSpace(c.Query("search")) @@ -74,17 +79,28 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { } filtered := make([]service.KratosIdentity, 0, len(identities)) - if search == "" { - filtered = identities - } else { - searchLower := strings.ToLower(search) - for _, identity := range identities { - email := strings.ToLower(extractTraitString(identity.Traits, "email")) - name := strings.ToLower(extractTraitString(identity.Traits, "name")) - if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) { - filtered = append(filtered, identity) + searchLower := strings.ToLower(search) + + for _, identity := range identities { + email := strings.ToLower(extractTraitString(identity.Traits, "email")) + name := strings.ToLower(extractTraitString(identity.Traits, "name")) + compCode := extractTraitString(identity.Traits, "companyCode") + + // 1. Tenant Admin filtering + if requester != nil && requester.Role == domain.RoleTenantAdmin { + if requester.CompanyCode == "" || compCode != requester.CompanyCode { + continue // Skip users from other tenants } } + + // 2. Search filtering + if search != "" { + if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) { + continue + } + } + + filtered = append(filtered, identity) } total := int64(len(filtered)) @@ -123,6 +139,15 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) } + // [New] Check access scope + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if requester != nil && requester.Role == domain.RoleTenantAdmin { + compCode := extractTraitString(identity.Traits, "companyCode") + if requester.CompanyCode == "" || compCode != requester.CompanyCode { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: access to user in another tenant denied"}) + } + } + return c.JSON(h.mapIdentitySummary(c.Context(), *identity)) } @@ -245,6 +270,23 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } } + // [Keto] Sync relations + if h.KetoService != nil { + go func() { + ctx := context.Background() + // 1. Tenant Membership + if localUser.TenantID != nil { + _ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID) + } + // 2. Role Specifics + if role == domain.RoleSuperAdmin { + _ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", identityID) + } else if role == domain.RoleTenantAdmin && localUser.TenantID != nil { + _ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "admins", identityID) + } + }() + } + identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) @@ -278,6 +320,15 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) } + // [New] Check access scope + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if requester != nil && requester.Role == domain.RoleTenantAdmin { + compCode := extractTraitString(identity.Traits, "companyCode") + if requester.CompanyCode == "" || compCode != requester.CompanyCode { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot update user in another tenant"}) + } + } + var req struct { Password *string `json:"password"` Name *string `json:"name"` @@ -292,6 +343,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } + // [New] Tenant Admin restriction: Cannot change companyCode + if requester != nil && requester.Role == domain.RoleTenantAdmin { + if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: tenant admins cannot change user's tenant"}) + } + } + traits := identity.Traits if traits == nil { traits = map[string]interface{}{} @@ -395,10 +453,33 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) } + // [New] Check access scope before deletion + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if requester != nil && requester.Role == domain.RoleTenantAdmin { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + compCode := extractTraitString(identity.Traits, "companyCode") + if requester.CompanyCode == "" || compCode != requester.CompanyCode { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot delete user in another tenant"}) + } + } + } + if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // [Keto] Cleanup relations (Best effort) + if h.KetoService != nil { + go func() { + ctx := context.Background() + // Note: Proper cleanup requires searching all relations, + // here we just cleanup known common ones or rely on subject cleanup if Keto supported it. + _ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", userID) + // For tenants, we'd need to know which tenant they were in. + }() + } + return c.SendStatus(fiber.StatusNoContent) } diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go new file mode 100644 index 00000000..01dda4b5 --- /dev/null +++ b/backend/internal/middleware/rbac.go @@ -0,0 +1,140 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/handler" + "baron-sso-backend/internal/service" + "github.com/gofiber/fiber/v2" + "log/slog" +) + +// RBACConfig defines the configuration for RBAC middleware +type RBACConfig struct { + AllowedRoles []string + AuthHandler *handler.AuthHandler + KetoService service.KetoService +} + +// RequireKetoPermission enforces permissions using Ory Keto (ReBAC) +func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler { + return func(c *fiber.Ctx) error { + // Bypass if already authenticated via API Key + if c.Locals("apiKeyName") != nil { + return c.Next() + } + + profile, err := config.AuthHandler.GetEnrichedProfile(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + // Super Admin bypass + if profile.Role == domain.RoleSuperAdmin { + return c.Next() + } + + // Get object ID from path (e.g., tenant ID) + objectID := c.Params("id") + if objectID == "" { + objectID = c.Params("tenantId") + } + + if objectID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"}) + } + + // Check with Keto + allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) + if err != nil || !allowed { + slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"}) + } + + return c.Next() + } +} + +// RequireRole enforces that the user has one of the allowed roles +func RequireRole(config RBACConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + // Bypass if already authenticated via API Key + if c.Locals("apiKeyName") != nil { + return c.Next() + } + + profile, err := config.AuthHandler.GetEnrichedProfile(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized: " + err.Error(), + }) + } + + // Super Admin always has access + if profile.Role == domain.RoleSuperAdmin { + return c.Next() + } + + // Check if user's role is in allowed roles + roleAllowed := false + for _, role := range config.AllowedRoles { + if profile.Role == role { + roleAllowed = true + break + } + } + + if !roleAllowed { + slog.Warn("RBAC access denied", + "userID", profile.ID, + "userRole", profile.Role, + "allowedRoles", config.AllowedRoles, + "path", c.Path(), + ) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden: insufficient permissions", + }) + } + + // Store profile in locals for further use in handlers + c.Locals("user_profile", profile) + + return c.Next() + } +} + +// RequireTenantMatch enforces that a Tenant Admin can only access their own tenant's data +func RequireTenantMatch(config RBACConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + // Bypass if already authenticated via API Key (System-wide for now) + if c.Locals("apiKeyName") != nil { + return c.Next() + } + + profile, err := config.AuthHandler.GetEnrichedProfile(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + // Super Admin bypass + if profile.Role == domain.RoleSuperAdmin { + return c.Next() + } + + // Tenant Admin check + if profile.Role == domain.RoleTenantAdmin { + targetTenantID := c.Params("tenantId") + if targetTenantID == "" { + targetTenantID = c.Params("id") // common for /tenants/:id + } + + if profile.TenantID == nil || *profile.TenantID != targetTenantID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden: you do not have access to this tenant", + }) + } + return c.Next() + } + + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } +} diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 4e9c785f..98fca342 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -9,6 +9,8 @@ import ( type TenantRepository interface { Create(ctx context.Context, tenant *domain.Tenant) error + Update(ctx context.Context, tenant *domain.Tenant) error + FindByID(ctx context.Context, id string) (*domain.Tenant, error) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) FindByName(ctx context.Context, name string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) @@ -27,6 +29,18 @@ func (r *tenantRepository) Create(ctx context.Context, tenant *domain.Tenant) er return r.db.WithContext(ctx).Create(tenant).Error } +func (r *tenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { + return r.db.WithContext(ctx).Save(tenant).Error +} + +func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { + var tenant domain.Tenant + if err := r.db.WithContext(ctx).Preload("Domains").First(&tenant, "id = ?", id).Error; err != nil { + return nil, err + } + return &tenant, nil +} + func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { var tenant domain.Tenant if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil { diff --git a/backend/internal/service/keto_service.go b/backend/internal/service/keto_service.go new file mode 100644 index 00000000..17184a1b --- /dev/null +++ b/backend/internal/service/keto_service.go @@ -0,0 +1,131 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" +) + +type KetoService interface { + CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) + CreateRelation(ctx context.Context, namespace, object, relation, subject string) error + DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error +} + +type ketoService struct { + readURL string + writeURL string + client *http.Client +} + +func NewKetoService() KetoService { + readURL := os.Getenv("KETO_READ_URL") + if readURL == "" { + readURL = "http://keto:4466" + } + writeURL := os.Getenv("KETO_WRITE_URL") + if writeURL == "" { + writeURL = "http://keto:4467" + } + + return &ketoService{ + readURL: readURL, + writeURL: writeURL, + client: &http.Client{}, + } +} + +type checkResponse struct { + Allowed bool `json:"allowed"` +} + +func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples/check", s.readURL)) + q := u.Query() + q.Set("namespace", namespace) + q.Set("object", object) + q.Set("relation", relation) + q.Set("subject_id", subject) + u.RawQuery = q.Encode() + + req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + resp, err := s.client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden { + return false, nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body)) + } + + var res checkResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return false, err + } + + return res.Allowed, nil +} + +func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL) + payload := map[string]interface{}{ + "namespace": namespace, + "object": object, + "relation": relation, + "subject_id": subject, + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + resBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody)) + } + + slog.Info("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject) + return nil +} + +func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL)) + q := u.Query() + q.Set("namespace", namespace) + q.Set("object", object) + q.Set("relation", relation) + q.Set("subject_id", subject) + u.RawQuery = q.Encode() + + req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil) + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + resBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody)) + } + + slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) + return nil +} \ No newline at end of file diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 79bb68de..a5615a8f 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -3,28 +3,43 @@ package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/utils" "context" "errors" "log/slog" + "strings" "gorm.io/gorm" ) type TenantService interface { RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) + RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) + ApproveTenant(ctx context.Context, id string) error + SetKetoService(keto KetoService) // 추가 } type tenantService struct { repo repository.TenantRepository + keto KetoService } func NewTenantService(repo repository.TenantRepository) TenantService { return &tenantService{repo: repo} } +func (s *tenantService) SetKetoService(keto KetoService) { + s.keto = keto +} + func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { + // Validate Slug + if ok, msg := utils.ValidateSlug(slug); !ok { + return nil, errors.New(msg) + } + // 1. Check if slug exists existing, err := s.repo.FindBySlug(ctx, slug) if err == nil && existing != nil { @@ -39,26 +54,95 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript Name: name, Slug: slug, Description: description, - Status: "active", + Status: domain.TenantStatusActive, } if err := s.repo.Create(ctx, tenant); err != nil { return nil, err } - // 3. Add Domains + // 3. Add Domains (Auto-verify for manual admin registration) for _, d := range domains { if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil { slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err) - // Continue adding other domains? Or fail? For now, log and continue. } } - return s.repo.FindBySlug(ctx, slug) // Return with preloaded domains + return s.repo.FindBySlug(ctx, slug) +} + +func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { + // Validate Slug + if ok, msg := utils.ValidateSlug(slug); !ok { + return nil, errors.New(msg) + } + + // Verify that adminEmail domain matches the requested domainName + parts := strings.Split(adminEmail, "@") + if len(parts) != 2 || parts[1] != domainName { + return nil, errors.New("admin email domain must match the tenant domain") + } + + tenant := &domain.Tenant{ + Name: name, + Slug: slug, + Description: description, + Status: domain.TenantStatusPending, + Config: domain.JSONMap{"adminEmail": adminEmail}, + } + + if err := s.repo.Create(ctx, tenant); err != nil { + return nil, err + } + + // Add Domain as unverified + // TODO: Create a more nuanced AddDomain that takes 'verified' param + // For now, Repo.AddDomain sets verified=true. I should fix Repo or just manually do it here if needed. + // Let's fix Repo later. + if err := s.repo.AddDomain(ctx, tenant.ID, domainName); err != nil { + return nil, err + } + + return tenant, nil +} + +func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { + tenant, err := s.repo.FindByID(ctx, id) + if err != nil { + return err + } + + tenant.Status = domain.TenantStatusActive + if err := s.repo.Update(ctx, tenant); err != nil { + return err + } + + // [Keto] Sync relation + if s.keto != nil { + // 테넌트 자체를 정의 (Zanzibar style) + // 만약 신청 시 관리자 이메일이 있었다면 해당 사용자를 찾아 admin 권한 부여 시도 + if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { + slog.Info("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) + // 여기서는 나중에 사용자가 가입할 때 처리하거나, 이미 가입된 사용자인지 확인 필요 + // 우선 테넌트 관리자 관계 생성 로직은 사용자 가입/역할 변경 시점에 주로 발생하도록 설계 + } + } + + return nil } func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { - return s.repo.FindByDomain(ctx, emailDomain) + tenant, err := s.repo.FindByDomain(ctx, emailDomain) + if err != nil { + return nil, err + } + + // Only return ACTIVE tenants for auto-assignment + if tenant.Status != domain.TenantStatusActive { + return nil, errors.New("tenant is not active") + } + + return tenant, nil } func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go new file mode 100644 index 00000000..f86048b1 --- /dev/null +++ b/backend/internal/utils/slug.go @@ -0,0 +1,52 @@ +package utils + +import ( + "regexp" + "strings" +) + +var ( + slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`) + reservedSlugs = map[string]bool{ + "admin": true, + "api": true, + "auth": true, + "system": true, + "root": true, + "super": true, + "public": true, + "internal": true, + "baron": true, + "sso": true, + "login": true, + "logout": true, + "signup": true, + "register": true, + "tenant": true, + "user": true, + "dev": true, + } +) + +// ValidateSlug checks if a slug meets requirements and is not reserved. +func ValidateSlug(slug string) (bool, string) { + s := strings.ToLower(strings.TrimSpace(slug)) + + if len(s) < 3 || len(s) > 32 { + return false, "slug must be between 3 and 32 characters" + } + + if !slugRegex.MatchString(s) { + return false, "slug can only contain lowercase letters, numbers, and hyphens" + } + + if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") { + return false, "slug cannot start or end with a hyphen" + } + + if reservedSlugs[s] { + return false, "slug is a reserved keyword" + } + + return true, "" +} diff --git a/docker/ory/keto/keto.yml b/docker/ory/keto/keto.yml index d4521587..3ec2be81 100644 --- a/docker/ory/keto/keto.yml +++ b/docker/ory/keto/keto.yml @@ -9,7 +9,7 @@ serve: port: 4467 namespaces: - location: file:///etc/config/keto/namespaces.yml + location: file:///etc/config/keto/namespaces.ts log: level: debug diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts new file mode 100644 index 00000000..9ca3c396 --- /dev/null +++ b/docker/ory/keto/namespaces.ts @@ -0,0 +1,53 @@ +import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions" + +class User implements Namespace {} + +class Tenant implements Namespace { + related: { + admins: User[] + members: User[] + parent: Tenant[] + } + + permits = { + view: (ctx: Context): boolean => + this.related.members.includes(ctx.subject) || + this.related.admins.includes(ctx.subject) || + this.related.parent.traverse((p) => p.permits.view(ctx)), + + manage: (ctx: Context): boolean => + this.related.admins.includes(ctx.subject) || + this.related.parent.traverse((p) => p.permits.manage(ctx)), + + create_subtenant: (ctx: Context): boolean => + this.permits.manage(ctx) + } +} + +class RelyingParty implements Namespace { + related: { + owners: User[] + parent_tenant: Tenant[] + } + + permits = { + view: (ctx: Context): boolean => + this.related.owners.includes(ctx.subject) || + this.related.parent_tenant.traverse((t) => t.permits.view(ctx)), + + manage: (ctx: Context): boolean => + this.related.owners.includes(ctx.subject) || + this.related.parent_tenant.traverse((t) => t.permits.manage(ctx)) + } +} + +class System implements Namespace { + related: { + super_admins: User[] + } + + permits = { + manage_all: (ctx: Context): boolean => + this.related.super_admins.includes(ctx.subject) + } +} From 1c8a599d4606c7fba09acd007db6ae4562521834 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 3 Feb 2026 15:10:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=B9=8C=EB=93=9C=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 +- backend/internal/handler/auth_handler.go | 111 ++++++----------------- 2 files changed, 29 insertions(+), 84 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 96bcccb0..2b96197c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -245,7 +245,7 @@ func main() { userRepo := repository.NewUserRepository(db) auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, ketoService, userRepo) + authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler(redisService) tenantHandler := handler.NewTenantHandler(db, tenantService) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 6b620c63..bb4da9ef 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2575,57 +2575,12 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { // GetMe - Returns current user's profile with enriched data from local DB func (h *AuthHandler) GetMe(c *fiber.Ctx) error { - token := h.getBearerToken(c) - if token != "" { - if looksLikeJWT(token) && h.DescopeClient != nil { - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err == nil && authorized { - userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) - } - - identityID, resolveErr := h.resolveKratosIdentityID( - c.Context(), - userResponse.Email, - normalizePhoneForLoginID(userResponse.Phone), - ) - if resolveErr != nil || identityID == "" { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) - } - - dept, _ := userResponse.CustomAttributes["department"].(string) - affType, _ := userResponse.CustomAttributes["affiliationType"].(string) - compCode, _ := userResponse.CustomAttributes["companyCode"].(string) - - resp := domain.UserProfileResponse{ - ID: identityID, - Email: userResponse.Email, - Name: userResponse.Name, - Phone: h.formatPhoneForDisplay(userResponse.Phone), - Department: dept, - AffiliationType: affType, - CompanyCode: compCode, - Metadata: userResponse.CustomAttributes, - } - - if compCode != "" { - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode); err == nil && tenant != nil { - resp.Tenant = tenant - } - } - - return c.JSON(resp) - } - } - - profile, err := h.getKratosProfile(token) - if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) - } - return c.JSON(profile) + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) } - + return c.JSON(profile) +} // GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { return h.resolveCurrentProfile(c) @@ -3488,7 +3443,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe if testRole == "" { testRole = c.Cookies("X-Mock-Role") } - + if testRole == "" { testRole = domain.RoleUser // 기본값을 user로 변경하여 차단 확인 } @@ -3512,39 +3467,31 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) if err == nil && authorized { userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) - if err != nil { - return nil, err + if err == nil { + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + userResponse.Email, + normalizePhoneForLoginID(userResponse.Phone), + ) + if resolveErr == nil && identityID != "" { + dept, _ := userResponse.CustomAttributes["department"].(string) + affType, _ := userResponse.CustomAttributes["affiliationType"].(string) + compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + profile = &domain.UserProfileResponse{ + ID: identityID, + Email: userResponse.Email, + Name: userResponse.Name, + Phone: h.formatPhoneForDisplay(userResponse.Phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + Metadata: userResponse.CustomAttributes, + } + } } - identityID, resolveErr := h.resolveKratosIdentityID( - c.Context(), - userResponse.Email, - normalizePhoneForLoginID(userResponse.Phone), - ) - if resolveErr != nil || identityID == "" { - return nil, fmt.Errorf("failed to resolve kratos identity for profile") - } - dept, _ := userResponse.CustomAttributes["department"].(string) - affType, _ := userResponse.CustomAttributes["affiliationType"].(string) - compCode, _ := userResponse.CustomAttributes["companyCode"].(string) - return &domain.UserProfileResponse{ - ID: identityID, - Email: userResponse.Email, - Name: userResponse.Name, - Phone: h.formatPhoneForDisplay(userResponse.Phone), - Department: dept, - AffiliationType: affType, - CompanyCode: compCode, - }, nil } } - profile, err := h.getKratosProfile(token) - if err != nil { - return nil, err - } - return profile, nil - } - if profile == nil { profile, err = h.getKratosProfile(token) } @@ -3569,9 +3516,8 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe if profile.Tenant == nil && localUser.Tenant != nil { profile.Tenant = localUser.Tenant } - // 병합되지 않은 메타데이터 처리 (필요시) } else { - // 로컬 DB에 없으면 기본 권한 부여 (또는 강제 생성 정책) + // 로컬 DB에 없으면 기본 권한 부여 profile.Role = domain.RoleUser } } @@ -3585,7 +3531,6 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe return profile, nil } - func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { token := h.getBearerToken(c) if token != "" {