1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/main'

This commit is contained in:
Lectom C Han
2026-02-03 16:50:11 +09:00
24 changed files with 1073 additions and 156 deletions

View File

@@ -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() {
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />
</div>
</div>
);

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
const RoleSwitcher: React.FC = () => {
const [currentRole, setCurrentRole] = useState<string>('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 (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
background: '#1A1F2C',
color: 'white',
padding: '10px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: '12px'
}}>
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
🛠 DEV Role Switcher
</div>
{(['super_admin', 'tenant_admin', 'user'] as const).map(role => (
<button
key={role}
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? '#3b82f6' : '#333',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.2s'
}}
>
{role.toUpperCase()} {currentRole === role ? '✅' : ''}
</button>
))}
</div>
);
};
export default RoleSwitcher;

View File

@@ -127,7 +127,18 @@ function TenantListPage() {
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge
variant={tenant.status === "active" ? "default" : "muted"}
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
className={
tenant.status === "pending"
? "bg-yellow-100 text-yellow-800"
: ""
}
>
{tenant.status}
</Badge>

View File

@@ -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 <div>Tenant ID is missing</div>;
@@ -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 (
<>
<Card className="bg-[var(--color-panel)] mt-6">
@@ -168,6 +187,16 @@ export function TenantProfilePage() {
Delete
</Button>
<div className="flex items-center gap-2">
{status === "pending" && (
<Button
variant="default"
className="bg-green-600 hover:bg-green-700"
onClick={handleApprove}
disabled={approveMutation.isPending}
>
Approve Tenant
</Button>
)}
<Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel
</Button>

View File

@@ -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<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
);
return data;
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;

View File

@@ -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;
});

View File

@@ -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 ---")
}

View File

@@ -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("====================================================")
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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 == "" {

View File

@@ -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"`

View File

@@ -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 != "" {

View File

@@ -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"})

View File

@@ -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)
}

View File

@@ -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"})
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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, ""
}

View File

@@ -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

View File

@@ -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)
}
}