@@ -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 92483f17..2b96197c 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -214,6 +214,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" {
@@ -239,23 +241,24 @@ 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, oathkeeperRepo, tenantService, userRepo)
+ authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, 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
appEnv := getEnv("APP_ENV", "dev")
app := fiber.New(fiber.Config{
AppName: "Baron SSO Backend",
- DisableStartupMessage: true, // Clean logs
+ DisableStartupMessage: true, // Clean logs
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
// Global Error Handler for Production Masking
ErrorHandler: func(c *fiber.Ctx, err error) error {
@@ -466,6 +469,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)
@@ -478,6 +484,15 @@ func main() {
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
+ 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)
@@ -511,25 +526,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 744a6fde..25dd8742 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -91,6 +91,7 @@ type AuthHandler struct {
OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService
TenantService service.TenantService
+ KetoService service.KetoService
UserRepo repository.UserRepository
}
@@ -149,7 +150,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, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
+func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *AuthHandler {
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
@@ -177,6 +178,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
OathkeeperRepo: oathkeeperRepo,
Hydra: service.NewHydraAdminService(),
TenantService: tenantService,
+ KetoService: ketoService,
UserRepo: userRepo,
}
}
@@ -405,16 +407,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."})
}
}
}
@@ -469,21 +481,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{
@@ -2555,69 +2573,18 @@ 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"})
- }
-
- 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)
- }
-
- 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
@@ -3539,52 +3506,101 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
}
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
+ 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
+ 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) {
token := h.getBearerToken(c)
if token != "" {
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)
+ }
+}