첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
FROM golang:1.26.2-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for go mod download if needed
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Pre-copy go.mod/sum to cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build for production (optional, can just run go run for dev)
|
||||
RUN go build -o main ./cmd/server
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command (can be overridden by compose)
|
||||
CMD ["./main"]
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
CompanyCode string
|
||||
Status string
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := "host=localhost user=baron password=password dbname=baron_sso port=5432 sslmode=disable TimeZone=Asia/Seoul"
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var users []User
|
||||
err = db.Raw("SELECT id, email, name, company_code, status FROM users WHERE company_code = 'aaa2' OR 'aaa2' = ANY(company_codes)").Scan(&users).Error
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Total users for aaa2: %d\n", len(users))
|
||||
for _, u := range users {
|
||||
fmt.Printf("- %s (%s) | status: %s | primary: %s\n", u.Name, u.Email, u.Status, u.CompanyCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/bootstrap"
|
||||
"baron-sso-backend/internal/idp"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type createSuperAdminConfig struct {
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
UpdatePassword bool
|
||||
}
|
||||
|
||||
type clearOrphanUserTenantMembershipsConfig struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadEnv()
|
||||
logger.Init(logger.Config{
|
||||
ServiceName: "baron-sso-adminctl",
|
||||
Environment: getenv("APP_ENV", getenv("GO_ENV", "dev")),
|
||||
LevelOverride: getenv("BACKEND_LOG_LEVEL", ""),
|
||||
})
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "create-super-admin":
|
||||
if err := runCreateSuperAdmin(os.Args[2:]); err != nil {
|
||||
slog.Error("create-super-admin failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "clear-orphan-user-tenant-memberships":
|
||||
if err := runClearOrphanUserTenantMemberships(os.Args[2:]); err != nil {
|
||||
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "worksmobile-sync":
|
||||
if err := runWorksmobileSync(os.Args[2:]); err != nil {
|
||||
slog.Error("worksmobile-sync failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
printUsage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runCreateSuperAdmin(args []string) error {
|
||||
config, err := resolveCreateSuperAdminConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bootstrap.Run(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider, err := idp.InitializeProvider()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if provider == nil {
|
||||
return fmt.Errorf("idp provider is required")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := bootstrap.EnsureSuperAdmin(
|
||||
ctx,
|
||||
service.NewKratosAdminService(),
|
||||
bootstrap.NewGormSuperAdminStore(db, repository.NewKetoOutboxRepository(db)),
|
||||
bootstrap.EnsureSuperAdminOptions{
|
||||
Email: config.Email,
|
||||
Password: config.Password,
|
||||
Name: config.Name,
|
||||
Source: "adminctl",
|
||||
UpdatePassword: config.UpdatePassword,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("super admin ensured: email=%s identity_id=%s user_id=%s identity_created=%t local_created=%t local_updated=%t password_updated=%t keto_relation_queued=%t\n",
|
||||
result.Email,
|
||||
result.IdentityID,
|
||||
result.LocalUserID,
|
||||
result.IdentityCreated,
|
||||
result.LocalUserCreated,
|
||||
result.LocalUserUpdated,
|
||||
result.PasswordUpdated,
|
||||
result.KetoRelationQueued,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClearOrphanUserTenantMemberships(args []string) error {
|
||||
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if config.DryRun {
|
||||
count, err := repository.CountOrphanUserTenantMemberships(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("orphan user tenant memberships dry-run: count=%d\n", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("orphan user tenant memberships cleared: count=%d\n", affected)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
|
||||
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
config := createSuperAdminConfig{}
|
||||
fs.StringVar(&config.Email, "email", getenv("ADMIN_EMAIL", ""), "admin email")
|
||||
fs.StringVar(&config.Password, "password", getenv("ADMIN_PASSWORD", ""), "admin password")
|
||||
fs.StringVar(&config.Name, "name", getenv("ADMIN_NAME", "System Admin"), "admin display name")
|
||||
fs.BoolVar(&config.UpdatePassword, "update-password", false, "update password when identity already exists")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
config.Email = strings.TrimSpace(config.Email)
|
||||
config.Name = strings.TrimSpace(config.Name)
|
||||
if config.Email == "" {
|
||||
return config, fmt.Errorf("admin email is required; pass --email or set ADMIN_EMAIL")
|
||||
}
|
||||
if strings.TrimSpace(config.Password) == "" {
|
||||
return config, fmt.Errorf("admin password is required; pass --password or set ADMIN_PASSWORD")
|
||||
}
|
||||
if config.Name == "" {
|
||||
config.Name = "System Admin"
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUserTenantMembershipsConfig, error) {
|
||||
fs := flag.NewFlagSet("clear-orphan-user-tenant-memberships", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
config := clearOrphanUserTenantMembershipsConfig{}
|
||||
fs.BoolVar(&config.DryRun, "dry-run", false, "count orphan memberships without updating users")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func openDB() (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
|
||||
getenv("DB_HOST", "localhost"),
|
||||
getenv("DB_USER", "baron"),
|
||||
getenv("DB_PASSWORD", "password"),
|
||||
getenv("DB_NAME", "baron_sso"),
|
||||
getenv("DB_PORT", "5432"),
|
||||
)
|
||||
return gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
gormLogger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: gormLogger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
func loadEnv() {
|
||||
_ = godotenv.Load(".env")
|
||||
_ = godotenv.Load("../.env")
|
||||
_ = godotenv.Load("../../.env")
|
||||
}
|
||||
|
||||
func getenv(key string, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCreateSuperAdminConfigUsesEnvDefaults(t *testing.T) {
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
t.Setenv("ADMIN_PASSWORD", "Password!123")
|
||||
t.Setenv("ADMIN_NAME", "Env Admin")
|
||||
|
||||
config, err := resolveCreateSuperAdminConfig([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if config.Email != "admin@example.com" {
|
||||
t.Fatalf("email = %q", config.Email)
|
||||
}
|
||||
if config.Password != "Password!123" {
|
||||
t.Fatal("password was not read from ADMIN_PASSWORD")
|
||||
}
|
||||
if config.Name != "Env Admin" {
|
||||
t.Fatalf("name = %q", config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCreateSuperAdminConfigAllowsFlagOverrides(t *testing.T) {
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
t.Setenv("ADMIN_PASSWORD", "Password!123")
|
||||
t.Setenv("ADMIN_NAME", "Env Admin")
|
||||
|
||||
config, err := resolveCreateSuperAdminConfig([]string{
|
||||
"--email", "flag@example.com",
|
||||
"--password", "FlagPassword!123",
|
||||
"--name", "Flag Admin",
|
||||
"--update-password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if config.Email != "flag@example.com" {
|
||||
t.Fatalf("email = %q", config.Email)
|
||||
}
|
||||
if config.Password != "FlagPassword!123" {
|
||||
t.Fatal("password flag was not used")
|
||||
}
|
||||
if config.Name != "Flag Admin" {
|
||||
t.Fatalf("name = %q", config.Name)
|
||||
}
|
||||
if !config.UpdatePassword {
|
||||
t.Fatal("update password flag was not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) {
|
||||
t.Setenv("ADMIN_EMAIL", "")
|
||||
t.Setenv("ADMIN_PASSWORD", "")
|
||||
|
||||
if _, err := resolveCreateSuperAdminConfig([]string{}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClearOrphanUserTenantMembershipsConfig(t *testing.T) {
|
||||
config, err := resolveClearOrphanUserTenantMembershipsConfig([]string{"--dry-run"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveClearOrphanUserTenantMembershipsConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if !config.DryRun {
|
||||
t.Fatal("dry-run flag was not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T) {
|
||||
client := &fakeWorksmobilePhoneAuditClient{
|
||||
users: []service.WorksmobileRemoteUser{
|
||||
{
|
||||
ID: "works-user-1",
|
||||
ExternalID: "baron-user-1",
|
||||
Email: "one@example.com",
|
||||
DisplayName: "One",
|
||||
CellPhone: "+82 +821091917771",
|
||||
DomainID: 1001,
|
||||
DomainName: "samaneng.com",
|
||||
},
|
||||
{
|
||||
ID: "works-user-2",
|
||||
Email: "two@example.com",
|
||||
CellPhone: "+821012345678",
|
||||
DomainID: 1001,
|
||||
},
|
||||
},
|
||||
}
|
||||
output := &strings.Builder{}
|
||||
|
||||
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
|
||||
if err != nil {
|
||||
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("count=%d, want 1", count)
|
||||
}
|
||||
if !strings.Contains(output.String(), "one@example.com") || !strings.Contains(output.String(), "+821091917771") {
|
||||
t.Fatalf("audit output did not include normalized duplicate phone row: %s", output.String())
|
||||
}
|
||||
if len(client.patches) != 1 {
|
||||
t.Fatalf("patch count=%d, want 1", len(client.patches))
|
||||
}
|
||||
if client.patches[0].identifier != "works-user-1" {
|
||||
t.Fatalf("patch identifier=%q, want works-user-1", client.patches[0].identifier)
|
||||
}
|
||||
if client.patches[0].payload.CellPhone != "+821091917771" {
|
||||
t.Fatalf("patch cellPhone=%q, want +821091917771", client.patches[0].payload.CellPhone)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWorksmobilePhoneAuditClient struct {
|
||||
users []service.WorksmobileRemoteUser
|
||||
patches []fakeWorksmobilePhonePatch
|
||||
}
|
||||
|
||||
type fakeWorksmobilePhonePatch struct {
|
||||
identifier string
|
||||
payload service.WorksmobileUserPatchPayload
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePhoneAuditClient) ListUsers(ctx context.Context) ([]service.WorksmobileRemoteUser, error) {
|
||||
return f.users, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePhoneAuditClient) PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error {
|
||||
f.patches = append(f.patches, fakeWorksmobilePhonePatch{identifier: identifier, payload: payload})
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyWorksmobileAlignFromWorksAllowsDomainOnlyEmailMismatch(t *testing.T) {
|
||||
item := service.WorksmobileComparisonItem{
|
||||
BaronEmail: "user@typo.example.com",
|
||||
WorksmobileEmail: "user@example.com",
|
||||
}
|
||||
|
||||
status, ok := classifyWorksmobileAlignFromWorks(item)
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("expected domain-only email mismatch to be alignable, status=%s", status)
|
||||
}
|
||||
if status != "updated" {
|
||||
t.Fatalf("expected updated status, got %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWorksmobileAlignFromWorksSkipsLocalPartChange(t *testing.T) {
|
||||
item := service.WorksmobileComparisonItem{
|
||||
BaronEmail: "old@example.com",
|
||||
WorksmobileEmail: "new@example.com",
|
||||
}
|
||||
|
||||
status, ok := classifyWorksmobileAlignFromWorks(item)
|
||||
|
||||
if ok {
|
||||
t.Fatalf("expected local-part change to be skipped")
|
||||
}
|
||||
if status != "skipped_email_local_part_changed" {
|
||||
t.Fatalf("expected skipped_email_local_part_changed status, got %s", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", true, "변경 대상만 출력하고 Kratos identity를 수정하지 않습니다")
|
||||
maintenanceWindow := flag.Bool("maintenance-window", false, "승인된 정비 시간에만 실제 변경을 허용합니다")
|
||||
markMirrorStale := flag.Bool("mark-mirror-stale", false, "실행 전 Redis identity mirror를 stale로 표시했음을 확인합니다")
|
||||
flag.Parse()
|
||||
|
||||
if !*dryRun && (!*maintenanceWindow || !*markMirrorStale) {
|
||||
log.Fatal("refusing to update Kratos identities: pass --dry-run=false --maintenance-window --mark-mirror-stale after marking identity mirror stale")
|
||||
}
|
||||
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
ctx := context.Background()
|
||||
|
||||
identities, err := kratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list identities: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, id := range identities {
|
||||
traits := id.Traits
|
||||
changed := false
|
||||
|
||||
if r, ok := traits["role"].(string); ok {
|
||||
norm := domain.NormalizeRole(r)
|
||||
if norm != r && norm == domain.RoleUser {
|
||||
traits["role"] = norm
|
||||
changed = true
|
||||
}
|
||||
} else if g, ok := traits["grade"].(string); ok {
|
||||
if norm, ok := domain.NormalizeRoleAlias(g); ok {
|
||||
traits["role"] = norm
|
||||
delete(traits, "grade")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if *dryRun {
|
||||
count++
|
||||
fmt.Printf("Would update %s\n", id.ID)
|
||||
continue
|
||||
}
|
||||
_, err := kratosAdmin.UpdateIdentity(ctx, id.ID, traits, id.State)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update %s: %v", id.ID, err)
|
||||
} else {
|
||||
count++
|
||||
fmt.Printf("Updated %s\n", id.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if *dryRun {
|
||||
fmt.Printf("Total candidates: %d\n", count)
|
||||
} else {
|
||||
fmt.Printf("Total updated: %d\n", count)
|
||||
fmt.Println("Identity mirror was marked stale before maintenance; run full mirror refresh and drift report before trusting cached user lists.")
|
||||
}
|
||||
}
|
||||
@@ -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 ---")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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("====================================================")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/response"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func newErrorHandler(appEnv string) fiber.ErrorHandler {
|
||||
return func(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
if appEnv == "production" || appEnv == "stage" {
|
||||
if code >= 500 {
|
||||
slog.Error("Internal Server Error",
|
||||
"error", err.Error(),
|
||||
"path", c.Path(),
|
||||
"method", c.Method(),
|
||||
)
|
||||
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
return response.Error(c, code, response.StatusCode(code), err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_ProductionMasksServerError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return errors.New("database connection failed")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "Internal Server Error" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "internal_error" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_ProductionPassesClientError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/bad", func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "bad request payload")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/bad", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "bad request payload" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "bad_request" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_DevelopmentReturnsOriginalServerError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("dev")})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return errors.New("database connection failed")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "database connection failed" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "internal_error" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_MapsUnauthorizedCode(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/unauthorized", func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "missing token")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/unauthorized", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["code"] != "invalid_session" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldEnableDocs_DisabledOnlyInProduction(t *testing.T) {
|
||||
testCases := []struct {
|
||||
appEnv string
|
||||
want bool
|
||||
}{
|
||||
{appEnv: "production", want: false},
|
||||
{appEnv: "prod", want: false},
|
||||
{appEnv: "stage", want: true},
|
||||
{appEnv: "staging", want: true},
|
||||
{appEnv: "dev", want: true},
|
||||
{appEnv: "development", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
got := shouldEnableDocs(tc.appEnv)
|
||||
if got != tc.want {
|
||||
t.Fatalf("appEnv=%s expected shouldEnableDocs=%v, got %v", tc.appEnv, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
authhandler "baron-sso-backend/internal/handler"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type e2eMockIdentityProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) Name() string {
|
||||
return "mock-idp"
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
args := m.Called(loginID, password)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.AuthInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) UserExists(loginID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) InitiatePasswordReset(loginID, redirectURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type e2eMockKratosAdminService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
ErrorHandler: newErrorHandler(appEnv),
|
||||
})
|
||||
|
||||
app.Use(requestid.New(requestid.Config{
|
||||
Generator: func() string {
|
||||
return "req-e2e-headless"
|
||||
},
|
||||
}))
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
|
||||
status := c.Response().StatusCode()
|
||||
if status < 400 {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := "http_request"
|
||||
if err != nil {
|
||||
msg = "http_request_error"
|
||||
}
|
||||
|
||||
slog.Info(msg,
|
||||
"status", status,
|
||||
"method", c.Method(),
|
||||
"path", c.Path(),
|
||||
"latency", time.Since(start).String(),
|
||||
"ip", c.IP(),
|
||||
"req_id", c.GetRespHeader(fiber.HeaderXRequestID),
|
||||
)
|
||||
return err
|
||||
})
|
||||
|
||||
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
|
||||
api := app.Group("/api/v1")
|
||||
auth := api.Group("/auth")
|
||||
auth.Post("/headless/password/login", h.HeadlessPasswordLogin)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func mustE2EHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate rsa key: %v", err)
|
||||
}
|
||||
|
||||
keySet := jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
Key: &privateKey.PublicKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(jose.RS256),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(keySet)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks: %v", err)
|
||||
}
|
||||
|
||||
var jwks map[string]any
|
||||
if err := json.Unmarshal(raw, &jwks); err != nil {
|
||||
t.Fatalf("failed to decode jwks map: %v", err)
|
||||
}
|
||||
|
||||
return privateKey, jwks
|
||||
}
|
||||
|
||||
func mustE2EHeadlessClientAssertion(t *testing.T, privateKey *rsa.PrivateKey, clientID, audience string) string {
|
||||
t.Helper()
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.RS256,
|
||||
Key: jose.JSONWebKey{
|
||||
Key: privateKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(jose.RS256),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create signer: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
raw, err := josejwt.Signed(signer).Claims(josejwt.Claims{
|
||||
Issuer: clientID,
|
||||
Subject: clientID,
|
||||
Audience: josejwt.Audience{audience},
|
||||
Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
IssuedAt: josejwt.NewNumericDate(now),
|
||||
NotBefore: josejwt.NewNumericDate(now.Add(-1 * time.Minute)),
|
||||
ID: "assertion-e2e",
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign client assertion: %v", err)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func mockHydraTransportForE2E(handler http.Handler) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w.Result(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func runHeadlessPasswordLoginE2E(
|
||||
t *testing.T,
|
||||
logger *slog.Logger,
|
||||
appEnv string,
|
||||
jwks map[string]any,
|
||||
clientAssertion string,
|
||||
) (*http.Response, string) {
|
||||
return runHeadlessPasswordLoginE2ERequest(
|
||||
t,
|
||||
logger,
|
||||
appEnv,
|
||||
jwks,
|
||||
clientAssertion,
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func runHeadlessPasswordLoginE2ERequest(
|
||||
t *testing.T,
|
||||
logger *slog.Logger,
|
||||
appEnv string,
|
||||
jwks map[string]any,
|
||||
clientAssertion string,
|
||||
requestURL string,
|
||||
headers map[string]string,
|
||||
) (*http.Response, string) {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless password login E2E tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(logger)
|
||||
t.Cleanup(func() {
|
||||
slog.SetDefault(previous)
|
||||
})
|
||||
|
||||
mockIDP := new(e2eMockIdentityProvider)
|
||||
mockIDP.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
|
||||
mockKratos := new(e2eMockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
jwksBody, err := json.Marshal(jwks)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &authhandler.AuthHandler{
|
||||
IdpProvider: mockIDP,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransportForE2E(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessLoginE2EApp(h, appEnv)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
return resp, logBuffer.String()
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs(t *testing.T) {
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
"https://rp.example.com/oidc/token",
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
resp, _ := runHeadlessPasswordLoginE2E(t, logger, "production", jwks, clientAssertion)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 401, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
|
||||
if got["code"] != "invalid_client_assertion_audience" {
|
||||
t.Fatalf("expected detailed code, got=%v", got["code"])
|
||||
}
|
||||
if got["error"] != "Client assertion audience mismatch" {
|
||||
t.Fatalf("expected detailed error message, got=%v", got["error"])
|
||||
}
|
||||
|
||||
output := logBuffer.String()
|
||||
if !strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||
t.Fatalf("expected headless failure log to include detailed reason code, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"req_id\":\"req-e2e-headless\"") {
|
||||
t.Fatalf("expected logs to include request id, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"path\":\"/api/v1/auth/headless/password/login\"") {
|
||||
t.Fatalf("expected request path in logs, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
receivedAudience,
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
resp, _ := runHeadlessPasswordLoginE2E(t, logger, "production", jwks, clientAssertion)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 401, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
output := logBuffer.String()
|
||||
if !strings.Contains(output, "\"expected_audiences\"") {
|
||||
t.Fatalf("expected debug logs to include expected_audiences, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"received_audiences\"") {
|
||||
t.Fatalf("expected debug logs to include received_audiences, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"received_audiences_text\":\""+receivedAudience+"\"") {
|
||||
t.Fatalf("expected debug logs to include received_audiences_text with full URL, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"expected_audiences_text\":\"http://example.com/api/v1/auth/headless/password/login, /api/v1/auth/headless/password/login\"") {
|
||||
t.Fatalf("expected debug logs to include expected_audiences_text, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"login_challenge_prefix\":\"challenge-12\"") {
|
||||
t.Fatalf("expected debug logs to include login challenge prefix, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
receivedAudience,
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
resp, output := runHeadlessPasswordLoginE2ERequest(
|
||||
t,
|
||||
logger,
|
||||
"production",
|
||||
jwks,
|
||||
clientAssertion,
|
||||
"http://sso.hmac.kr/api/v1/auth/headless/password/login",
|
||||
map[string]string{
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "sso.hmac.kr",
|
||||
},
|
||||
)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200 for forwarded https audience, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
if got["redirectTo"] != "http://rp/cb" {
|
||||
t.Fatalf("expected redirectTo, got=%v", got["redirectTo"])
|
||||
}
|
||||
|
||||
if strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||
t.Fatalf("did not expect audience mismatch log, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr")
|
||||
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
receivedAudience,
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
resp, output := runHeadlessPasswordLoginE2ERequest(
|
||||
t,
|
||||
logger,
|
||||
"production",
|
||||
jwks,
|
||||
clientAssertion,
|
||||
"http://sso.hmac.kr/api/v1/auth/headless/password/login",
|
||||
nil,
|
||||
)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200 for configured public https audience, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
if got["redirectTo"] != "http://rp/cb" {
|
||||
t.Fatalf("expected redirectTo, got=%v", got["redirectTo"])
|
||||
}
|
||||
if strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||
t.Fatalf("did not expect audience mismatch log, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPProbe struct {
|
||||
name string
|
||||
url string
|
||||
interval time.Duration
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
lastError string
|
||||
lastChecked time.Time
|
||||
lastSuccess time.Time
|
||||
}
|
||||
|
||||
type ProbeSnapshot struct {
|
||||
Status string
|
||||
Error string
|
||||
LastChecked time.Time
|
||||
LastSuccess time.Time
|
||||
}
|
||||
|
||||
func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe {
|
||||
if interval <= 0 {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
|
||||
return &HTTPProbe{
|
||||
name: name,
|
||||
url: url,
|
||||
interval: interval,
|
||||
timeout: timeout,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start는 프로브를 백그라운드에서 주기적으로 실행합니다.
|
||||
func (p *HTTPProbe) Start() {
|
||||
go func() {
|
||||
p.checkOnce()
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
p.checkOnce()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) Snapshot() ProbeSnapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return ProbeSnapshot{
|
||||
Status: p.status,
|
||||
Error: p.lastError,
|
||||
LastChecked: p.lastChecked,
|
||||
LastSuccess: p.lastSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) StatusText() string {
|
||||
s := p.Snapshot()
|
||||
if s.Status == "ok" {
|
||||
return "ok"
|
||||
}
|
||||
if s.Status == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if s.Error == "" {
|
||||
return "error"
|
||||
}
|
||||
return "error: " + s.Error
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) checkOnce() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
if err != nil {
|
||||
p.update("error", fmt.Sprintf("request build failed: %v", err), false)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
p.update("error", err.Error(), false)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false)
|
||||
return
|
||||
}
|
||||
|
||||
p.update("ok", "", true)
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) update(status, errMsg string, success bool) {
|
||||
p.mu.Lock()
|
||||
prevStatus := p.status
|
||||
p.status = status
|
||||
p.lastError = errMsg
|
||||
p.lastChecked = time.Now()
|
||||
if success {
|
||||
p.lastSuccess = p.lastChecked
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if prevStatus == status {
|
||||
return
|
||||
}
|
||||
if status == "ok" {
|
||||
slog.Info("Service probe recovered", "name", p.name, "url", p.url)
|
||||
return
|
||||
}
|
||||
slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg)
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/bootstrap"
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/handler"
|
||||
"baron-sso-backend/internal/idp"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/validator"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) {
|
||||
if path := strings.TrimSpace(getEnv(fileKey, "")); path != "" {
|
||||
value, err := readEnvFileValue(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
return getEnv(valueKey, fallback), nil
|
||||
}
|
||||
|
||||
func readEnvFileValue(path string) (string, error) {
|
||||
candidates := []string{path}
|
||||
if !filepath.IsAbs(path) {
|
||||
candidates = append(candidates, filepath.Join("..", path), filepath.Join("..", "..", path))
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err == nil {
|
||||
return string(data), nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", fmt.Errorf("read secret file %q: %w", path, lastErr)
|
||||
}
|
||||
|
||||
func normalizeDocsPrefix(prefix string) string {
|
||||
trimmed := strings.TrimSpace(prefix)
|
||||
if trimmed == "" || trimmed == "/" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") {
|
||||
trimmed = "/" + trimmed
|
||||
}
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
|
||||
func shouldEnableDocs(appEnv string) bool {
|
||||
env := strings.ToLower(strings.TrimSpace(appEnv))
|
||||
return env != "prod" && env != "production"
|
||||
}
|
||||
|
||||
func registerDocsRoutes(app *fiber.App, prefix string) {
|
||||
base := normalizeDocsPrefix(prefix)
|
||||
docsPath := base + "/docs"
|
||||
redocPath := base + "/redoc"
|
||||
openapiPath := base + "/openapi.yaml"
|
||||
|
||||
app.Get(docsPath, func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Get(docsPath+"/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Static(docsPath, "./docs/swagger-ui")
|
||||
|
||||
app.Get(redocPath, func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Get(redocPath+"/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Static(redocPath, "./docs/redoc")
|
||||
|
||||
app.Get(openapiPath, func(c *fiber.Ctx) error {
|
||||
c.Type("yaml")
|
||||
return c.SendFile("./docs/openapi.yaml")
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load .env file from possible paths
|
||||
// 1. .env (Current Directory)
|
||||
// 2. ../.env (Project Root when running from backend/)
|
||||
// 3. ../../.env (Project Root when running from backend/cmd/server/)
|
||||
if err := godotenv.Load(".env"); err != nil {
|
||||
if err := godotenv.Load("../.env"); err != nil {
|
||||
godotenv.Load("../../.env")
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Initialize Logger
|
||||
appEnvForLogger := getEnv("APP_ENV", getEnv("GO_ENV", "dev"))
|
||||
logger.Init(logger.Config{
|
||||
ServiceName: "baron-sso",
|
||||
Environment: appEnvForLogger,
|
||||
LevelOverride: getEnv("BACKEND_LOG_LEVEL", ""),
|
||||
})
|
||||
// Initialize Snowflake Node (Node 2 for Baron)
|
||||
node, err := snowflake.NewNode(2)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize snowflake node", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 1. Log Config on Startup
|
||||
fmt.Println("============================================================")
|
||||
fmt.Println(`
|
||||
|\__/,| (\
|
||||
_.|o o |_ ) )
|
||||
-(((---(((--------
|
||||
`)
|
||||
fmt.Println("🚀 Baron SSO Backend Starting...")
|
||||
|
||||
slog.Info("Service starting",
|
||||
"service", "baron-sso",
|
||||
"app_env", getEnv("APP_ENV", "dev"),
|
||||
"db_port", getEnv("DB_PORT", "5532"),
|
||||
"backend_port", getEnv("BACKEND_PORT", "3000"),
|
||||
"userfront_port", getEnv("USERFRONT_PORT", "5000"),
|
||||
"userfront_url", getEnv("USERFRONT_URL", "http://sso.hmac.kr"),
|
||||
"redis_addr", getEnv("REDIS_ADDR", "redis:6379"),
|
||||
)
|
||||
|
||||
// --- Fail-Fast Schema Validation ---
|
||||
// 팩토리를 사용하여 IDP 공급자를 초기화합니다.
|
||||
idpProvider, err := idp.InitializeProvider()
|
||||
if err != nil {
|
||||
slog.Error("❌ [CRITICAL] Failed to initialize IDP Provider", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil {
|
||||
slog.Error("❌ [CRITICAL] Broker Schema Mismatch",
|
||||
"idp", idpProvider.Name(),
|
||||
"error", err,
|
||||
)
|
||||
fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err)
|
||||
os.Exit(1) // Break the build/deployment
|
||||
}
|
||||
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
|
||||
// -----------------------------------
|
||||
|
||||
// 2. Initialize DB Connections
|
||||
// ClickHouse
|
||||
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
||||
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||
chUser := getEnv("CLICKHOUSE_USER", "baron")
|
||||
chPass := getEnv("CLICKHOUSE_PASSWORD", "password")
|
||||
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
|
||||
|
||||
var auditRepo domain.AuditRepository
|
||||
var rpUsageProjectionRepo domain.RPUsageProjectionRepository
|
||||
var rpUsageQueryRepo domain.RPUsageQueryRepository
|
||||
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
|
||||
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
|
||||
auditRepo = nil // Explicitly set to nil interface
|
||||
} else {
|
||||
auditRepo = repo
|
||||
rpUsageProjectionRepo = repo
|
||||
rpUsageQueryRepo = repo
|
||||
slog.Info("✅ Connected to ClickHouse")
|
||||
}
|
||||
|
||||
var oathkeeperRepo domain.OathkeeperLogRepository
|
||||
oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse")
|
||||
oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||
oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory")
|
||||
oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass")
|
||||
oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory")
|
||||
if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil {
|
||||
slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err)
|
||||
oathkeeperRepo = nil
|
||||
} else {
|
||||
oathkeeperRepo = repo
|
||||
slog.Info("✅ Connected to Ory ClickHouse")
|
||||
}
|
||||
|
||||
redisService, err := service.NewRedisService()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
||||
}
|
||||
|
||||
ketoService := service.NewKetoService()
|
||||
|
||||
// PostgreSQL (Meta Store)
|
||||
pgHost := getEnv("DB_HOST", "localhost")
|
||||
pgPort := getEnv("DB_PORT", "5432")
|
||||
pgUser := getEnv("DB_USER", "baron")
|
||||
pgPass := getEnv("DB_PASSWORD", "password")
|
||||
pgName := getEnv("DB_NAME", "baron_sso")
|
||||
|
||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
|
||||
pgHost, pgUser, pgPass, pgName, pgPort)
|
||||
|
||||
gormLog := gormLogger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
gormLogger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: gormLogger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLog,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
slog.Info("✅ Connected to PostgreSQL")
|
||||
|
||||
// Run Bootstrap (Migrations & Seeding)
|
||||
if err := bootstrap.Run(db); err != nil {
|
||||
slog.Error("❌ Bootstrap failed", "error", err)
|
||||
}
|
||||
|
||||
// [New] Initialize Keto Outbox and Worker
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db)
|
||||
ketoRelayWorker := service.NewKetoRelayWorker(ketoOutboxRepo, ketoService)
|
||||
go ketoRelayWorker.Start(context.Background())
|
||||
slog.Info("✅ Keto Relay Worker started")
|
||||
|
||||
// [Moved & Enhanced] Seed Admin Identity & Sync Local Role
|
||||
if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||
} else {
|
||||
// Sync role to local DB
|
||||
if err := bootstrap.SyncAdminRole(db, kratosID); err != nil {
|
||||
slog.Error("❌ Admin role sync failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Sync existing data to Keto
|
||||
if ketoOutboxRepo != nil {
|
||||
if err := bootstrap.SyncKetoRelations(db, ketoOutboxRepo); err != nil {
|
||||
slog.Warn("⚠️ Keto synchronization queueing failed during startup", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
|
||||
var oathkeeperProbe *HTTPProbe
|
||||
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
|
||||
intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10"))
|
||||
if err != nil || intervalSec <= 0 {
|
||||
intervalSec = 10
|
||||
}
|
||||
timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2"))
|
||||
if err != nil || timeoutSec <= 0 {
|
||||
timeoutSec = 2
|
||||
}
|
||||
oathkeeperProbe = NewHTTPProbe(
|
||||
"oathkeeper",
|
||||
getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"),
|
||||
time.Duration(intervalSec)*time.Second,
|
||||
time.Duration(timeoutSec)*time.Second,
|
||||
)
|
||||
oathkeeperProbe.Start()
|
||||
} else {
|
||||
slog.Info("Oathkeeper probe disabled")
|
||||
}
|
||||
|
||||
// 2. Initialize Handlers
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userProjectionRepo := repository.NewUserProjectionRepository(db)
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
|
||||
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
||||
sharedLinkRepo := repository.NewSharedLinkRepository(db)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
|
||||
userProjectionSyncer := service.NewUserProjectionSyncService(kratosAdminService, userProjectionRepo)
|
||||
if synced, err := userProjectionSyncer.Reconcile(context.Background()); err != nil {
|
||||
slog.Error("❌ Kratos user projection sync failed", "error", err)
|
||||
} else {
|
||||
slog.Info("✅ Kratos user projection synced", "users", synced)
|
||||
}
|
||||
|
||||
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
||||
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
||||
if err != nil {
|
||||
slog.Error("Worksmobile private key file could not be loaded", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
worksmobileClient := service.NewWorksmobileHTTPClientWithAuth(
|
||||
getEnv("WORKS_ADMIN_ACCESS_TOKEN", getEnv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN", "")),
|
||||
getEnv("SAMAN_SCIM_LONGLIVE_TOKEN", ""),
|
||||
service.WorksmobileOAuthConfig{
|
||||
ClientID: getEnv("WORKS_ADMIN_OAUTH_CLIENT_ID", ""),
|
||||
ClientSecret: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SECRET", ""),
|
||||
ServiceAccount: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""),
|
||||
PrivateKey: worksmobilePrivateKey,
|
||||
Scope: getEnv("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
||||
TokenURL: getEnv("WORKS_ADMIN_OAUTH_TOKEN_URL", ""),
|
||||
},
|
||||
)
|
||||
configureWorksmobileClientFromEnv(worksmobileClient)
|
||||
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
|
||||
worksmobileRelayClient := *worksmobileClient
|
||||
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
|
||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
|
||||
if lock := service.NewWorksmobileRedisRelayLeaderLock(redisService); lock != nil {
|
||||
worksmobileRelayWorker.SetLeaderLock(lock)
|
||||
}
|
||||
go worksmobileRelayWorker.Start(context.Background())
|
||||
slog.Info("✅ Worksmobile Relay Worker started")
|
||||
rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo)
|
||||
if rpUsageProjectionRepo != nil {
|
||||
rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo)
|
||||
go rpUsageProjectorWorker.Start(context.Background())
|
||||
slog.Info("✅ RP Usage Projector Worker started")
|
||||
} else {
|
||||
slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable")
|
||||
}
|
||||
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupService.SetWorksmobileSyncer(worksmobileService)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
|
||||
hydraService := service.NewHydraAdminService()
|
||||
headlessJWKSCache := service.NewHeadlessJWKSCacheService(redisService, nil)
|
||||
headlessJWKSWorker := service.NewHeadlessJWKSCacheWorker(hydraService, headlessJWKSCache)
|
||||
go headlessJWKSWorker.Start(context.Background())
|
||||
slog.Info("✅ Headless JWKS Cache Worker started")
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
consentRepo := repository.NewClientConsentRepository(db)
|
||||
rpUserMetadataRepo := repository.NewRPUserMetadataRepository(db)
|
||||
developerService := service.NewDeveloperService(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
authHandler.UserProjectionRepo = userProjectionRepo
|
||||
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
authHandler.RPUsageSink = rpUsageEmitter
|
||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||
adminHandler.DB = db
|
||||
adminHandler.RPUsageQueries = rpUsageQueryRepo
|
||||
adminHandler.TenantRepo = tenantRepo
|
||||
adminHandler.Hydra = hydraService
|
||||
adminHandler.AuditRepo = auditRepo
|
||||
adminHandler.UserProjectionRepo = userProjectionRepo
|
||||
adminHandler.IdentityCache = redisService
|
||||
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
devHandler.RPUsageQueries = rpUsageQueryRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo)
|
||||
tenantHandler.OrgChartCache = redisService
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||
userHandler.UserProjectionRepo = userProjectionRepo
|
||||
tenantHandler.SetWorksmobileSyncer(worksmobileService)
|
||||
userHandler.SetWorksmobileSyncer(worksmobileService)
|
||||
worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
|
||||
clientDebugEnabled := logger.ClientDebugEnabled(appEnv, clientLogDebugFlag)
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Baron SSO Backend",
|
||||
DisableStartupMessage: true, // Clean logs
|
||||
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
||||
ErrorHandler: newErrorHandler(appEnv),
|
||||
})
|
||||
|
||||
// Middleware
|
||||
app.Use(requestid.New(requestid.Config{
|
||||
Generator: func() string {
|
||||
return node.Generate().String()
|
||||
},
|
||||
}))
|
||||
|
||||
// [Standardized] HTTP Request Logger Middleware using slog
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
|
||||
// Handle request
|
||||
err := c.Next()
|
||||
|
||||
// Log after request
|
||||
latency := time.Since(start)
|
||||
status := c.Response().StatusCode()
|
||||
path := c.Path()
|
||||
|
||||
// Skip logging for all successful requests (status < 400)
|
||||
if status < 400 {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := "http_request"
|
||||
if err != nil {
|
||||
msg = "http_request_error"
|
||||
}
|
||||
|
||||
slog.Info(msg,
|
||||
"status", status,
|
||||
"method", c.Method(),
|
||||
"path", path,
|
||||
"latency", latency.String(),
|
||||
"ip", c.IP(),
|
||||
"req_id", c.GetRespHeader(fiber.HeaderXRequestID),
|
||||
)
|
||||
return err
|
||||
})
|
||||
|
||||
app.Use(recover.New(recover.Config{
|
||||
EnableStackTrace: true,
|
||||
}))
|
||||
|
||||
// Backfill `code` on legacy JSON error responses during migration period.
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
|
||||
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
|
||||
userfrontURL := getEnv("USERFRONT_URL", "http://sso.hmac.kr")
|
||||
baseDomain := ""
|
||||
if u, err := url.Parse(userfrontURL); err == nil {
|
||||
baseDomain = u.Hostname()
|
||||
}
|
||||
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOriginsFunc: func(origin string) bool {
|
||||
// 1. Check static allowed list
|
||||
for allowed := range strings.SplitSeq(allowedOrigins, ",") {
|
||||
if origin == strings.TrimSpace(allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse origin URL
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hostname := u.Hostname()
|
||||
|
||||
// 2. Check subdomains of base domain
|
||||
if baseDomain != "" && (hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. Check registered tenant domains
|
||||
// Use context.Background() as we don't have request context here easily
|
||||
allowed, _ := tenantService.IsDomainAllowed(context.Background(), hostname)
|
||||
return allowed
|
||||
},
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Test-Role, X-Mock-Role, X-Tenant-ID",
|
||||
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
|
||||
cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!")
|
||||
if len(cookieSecret) != 32 {
|
||||
slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret))
|
||||
if len(cookieSecret) > 32 {
|
||||
cookieSecret = cookieSecret[:32]
|
||||
} else {
|
||||
// Pad with '0' if too short
|
||||
cookieSecret = fmt.Sprintf("%-32s", cookieSecret)
|
||||
}
|
||||
}
|
||||
|
||||
app.Use(encryptcookie.New(encryptcookie.Config{
|
||||
Key: cookieSecret,
|
||||
}))
|
||||
|
||||
// [Security] Disable Swagger/ReDoc in Production
|
||||
if shouldEnableDocs(appEnv) {
|
||||
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
|
||||
registerDocsRoutes(app, "")
|
||||
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
|
||||
registerDocsRoutes(app, normalized)
|
||||
}
|
||||
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
|
||||
} else {
|
||||
slog.Info("🔒 API Docs disabled in production-like environment", "app_env", appEnv)
|
||||
}
|
||||
slog.Info("Client log policy configured",
|
||||
"app_env", appEnv,
|
||||
"client_debug_enabled", clientDebugEnabled,
|
||||
)
|
||||
|
||||
// Routes
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
return c.SendString("Baron SSO Audit Backend Online")
|
||||
})
|
||||
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
status := "ok"
|
||||
checks := make(map[string]string)
|
||||
|
||||
// Check ClickHouse
|
||||
if auditRepo != nil {
|
||||
if err := auditRepo.Ping(c.Context()); err != nil {
|
||||
checks["clickhouse"] = "error: " + err.Error()
|
||||
status = "error"
|
||||
} else {
|
||||
checks["clickhouse"] = "ok"
|
||||
}
|
||||
} else {
|
||||
checks["clickhouse"] = "not_initialized"
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
if redisService != nil {
|
||||
if err := redisService.Ping(c.Context()); err != nil {
|
||||
checks["redis"] = "error: " + err.Error()
|
||||
status = "error"
|
||||
} else {
|
||||
checks["redis"] = "ok"
|
||||
}
|
||||
} else {
|
||||
checks["redis"] = "not_initialized"
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
// Check Oathkeeper
|
||||
if oathkeeperProbe != nil {
|
||||
snapshot := oathkeeperProbe.Snapshot()
|
||||
switch snapshot.Status {
|
||||
case "ok":
|
||||
checks["oathkeeper"] = "ok"
|
||||
case "":
|
||||
checks["oathkeeper"] = "unknown"
|
||||
if status != "error" {
|
||||
status = "degraded"
|
||||
}
|
||||
default:
|
||||
if snapshot.Error == "" {
|
||||
checks["oathkeeper"] = "error"
|
||||
} else {
|
||||
checks["oathkeeper"] = "error: " + snapshot.Error
|
||||
}
|
||||
status = "error"
|
||||
}
|
||||
} else {
|
||||
checks["oathkeeper"] = "disabled"
|
||||
if status != "error" {
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
|
||||
if status == "error" {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
})
|
||||
})
|
||||
rpManifestHandler := handler.NewRPManifestHandler()
|
||||
app.Get("/.well-known/baron-rp-manifest", rpManifestHandler.GetHTML)
|
||||
app.Get("/.well-known/baron-rp-manifest.json", rpManifestHandler.GetJSON)
|
||||
app.Get("/.well-known/baron-rp-manifest.schema.json", rpManifestHandler.GetSchema)
|
||||
|
||||
// API Group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5"))
|
||||
queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000"))
|
||||
|
||||
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
|
||||
Repo: auditRepo,
|
||||
ExcludePaths: map[string]struct{}{
|
||||
"/api/v1/audit": {},
|
||||
"/api/v1/audit/auth/timeline": {},
|
||||
"/api/v1/client-log": {},
|
||||
"/api/v1/dev/audit-logs": {},
|
||||
},
|
||||
BodyDump: true,
|
||||
WorkerCount: workerCount,
|
||||
QueueSize: queueSize,
|
||||
}))
|
||||
api.Post("/audit", auditHandler.CreateLog)
|
||||
api.Get("/audit", auditHandler.ListLogs)
|
||||
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||
|
||||
// [New] Shared Link Public API (No Auth required)
|
||||
api.Get("/public/orgchart", tenantHandler.GetPublicOrgChart)
|
||||
|
||||
// Public Tenant Registration
|
||||
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||
api.Get("/admin/worksmobile/oauth/callback", worksmobileHandler.OAuthCallback)
|
||||
|
||||
integrationsAPI := api.Group("/integrations")
|
||||
integrationsAPI.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db}))
|
||||
integrationsAPI.Get("/org-context", tenantHandler.GetOrgContext)
|
||||
|
||||
// Tenant Context Middleware (identifies tenant from Host header)
|
||||
api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{
|
||||
TenantService: tenantService,
|
||||
}))
|
||||
|
||||
// Auth Proxy Routes
|
||||
auth := api.Group("/auth")
|
||||
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.Post("/headless/password/login", authHandler.HeadlessPasswordLogin)
|
||||
auth.Post("/headless/link/init", authHandler.HeadlessLinkInit)
|
||||
auth.Post("/headless/link/poll", authHandler.HeadlessLinkPoll)
|
||||
auth.Get("/tenant-info", authHandler.GetTenantInfo)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
auth.Post("/consent/reject", authHandler.RejectConsentRequest)
|
||||
|
||||
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
||||
|
||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||
auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage)
|
||||
auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage)
|
||||
// [Added] Use POST for actual verification triggered by the user
|
||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||
auth.Post("/sms", authHandler.SendSms)
|
||||
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||
auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
|
||||
|
||||
// Signup Routes
|
||||
signup := auth.Group("/signup")
|
||||
signup.Get("/tenants", authHandler.GetActiveTenants)
|
||||
signup.Post("/check-email", authHandler.CheckEmail)
|
||||
signup.Post("/check-login-id", authHandler.CheckLoginID)
|
||||
signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
|
||||
signup.Post("/send-sms-code", authHandler.SendSignupSmsCode)
|
||||
signup.Post("/verify-code", authHandler.VerifySignupCode)
|
||||
signup.Post("/", authHandler.Signup)
|
||||
|
||||
// User Routes (My Page)
|
||||
user := api.Group("/user")
|
||||
user.Get("/me", authHandler.GetMe)
|
||||
user.Put("/me", authHandler.UpdateMe)
|
||||
user.Post("/me/password", authHandler.ChangeMyPassword)
|
||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||
user.Get("/sessions", authHandler.ListMySessions)
|
||||
user.Delete("/sessions/:id", authHandler.DeleteMySession)
|
||||
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||
user.Get("/rp/history", authHandler.ListRpHistory)
|
||||
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)
|
||||
|
||||
// Admin Routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
||||
|
||||
// RBAC Middleware Instances
|
||||
requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||
AllowedRoles: []string{domain.RoleSuperAdmin},
|
||||
AuthHandler: authHandler,
|
||||
KetoService: ketoService,
|
||||
})
|
||||
requireAdmin := requireSuperAdmin // Simplified: only super_admin can access admin management routes
|
||||
requireAnyUser := middleware.RequireRole(middleware.RBACConfig{
|
||||
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleUser},
|
||||
AuthHandler: authHandler,
|
||||
KetoService: ketoService,
|
||||
})
|
||||
|
||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
||||
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
|
||||
admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
|
||||
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
|
||||
admin.Get("/ory/ssot", requireSuperAdmin, adminHandler.GetOrySSOTSystemStatus)
|
||||
admin.Post("/ory/ssot/identity-cache/flush", requireSuperAdmin, adminHandler.FlushIdentityCache)
|
||||
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
||||
admin.Get("/global-custom-claims", requireSuperAdmin, adminHandler.GetGlobalCustomClaimDefinitions)
|
||||
admin.Put("/global-custom-claims", requireSuperAdmin, adminHandler.UpdateGlobalCustomClaimDefinitions)
|
||||
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||
admin.Get("/orgchart/snapshot", requireAnyUser, tenantHandler.GetOrgChartSnapshot)
|
||||
admin.Get("/tenants/export", requireSuperAdmin, tenantHandler.ExportTenantsCSV)
|
||||
admin.Post("/tenants/import", requireSuperAdmin, tenantHandler.ImportTenantsCSV)
|
||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||
|
||||
// [New] Shared Link Management
|
||||
admin.Post("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.CreateShareLink)
|
||||
admin.Get("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.ListShareLinks)
|
||||
admin.Delete("/share-links/:id", requireAdmin, tenantHandler.DeleteShareLink)
|
||||
|
||||
admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
|
||||
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.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
||||
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||
admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners)
|
||||
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
||||
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||
|
||||
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
||||
admin.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteCredentialBatchPasswords)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
||||
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization")
|
||||
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
org.Get("/:id", userGroupHandler.Get)
|
||||
org.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||
org.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||
org.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||
org.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||
org.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||
org.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
|
||||
org.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
|
||||
|
||||
// Relying Party Management (Global List)
|
||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||
|
||||
// Relying Party Management (Tenant Context)
|
||||
admin.Post("/tenants/:tenantId/relying-parties",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "grant_dev_permissions"),
|
||||
relyingPartyHandler.Create)
|
||||
|
||||
admin.Get("/tenants/:tenantId/relying-parties",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view_dev_console"),
|
||||
relyingPartyHandler.List)
|
||||
|
||||
admin.Get("/relying-parties/:id",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
|
||||
relyingPartyHandler.Get)
|
||||
|
||||
admin.Put("/relying-parties/:id",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "edit_config"),
|
||||
relyingPartyHandler.Update)
|
||||
|
||||
admin.Delete("/relying-parties/:id",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||
relyingPartyHandler.Delete)
|
||||
|
||||
// Admin User Management
|
||||
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
|
||||
admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV)
|
||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
|
||||
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
|
||||
admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers)
|
||||
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
|
||||
admin.Get("/users/:id/rp-history", requireAdmin, userHandler.GetUserRpHistory)
|
||||
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
|
||||
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
|
||||
|
||||
// API Key Management (M2M) - Super Admin Only
|
||||
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
|
||||
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
|
||||
admin.Patch("/api-keys/:id", requireSuperAdmin, apiKeyHandler.UpdateApiKey)
|
||||
admin.Post("/api-keys/:id/secret/rotate", requireSuperAdmin, apiKeyHandler.RotateApiKeySecret)
|
||||
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||
|
||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||
dev := api.Group("/dev")
|
||||
dev.Get("/stats", devHandler.GetStats)
|
||||
dev.Get("/my-tenants", devHandler.ListMyTenants)
|
||||
dev.Get("/users", devHandler.SearchUsers)
|
||||
dev.Get("/clients", devHandler.ListClients)
|
||||
dev.Post("/clients", devHandler.CreateClient)
|
||||
dev.Get("/clients/:id/users/:userId/metadata", devHandler.GetRPUserMetadata)
|
||||
dev.Put("/clients/:id/users/:userId/metadata", devHandler.UpsertRPUserMetadata)
|
||||
dev.Get("/clients/:id", devHandler.GetClient)
|
||||
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
|
||||
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)
|
||||
dev.Delete("/clients/:id/relations", devHandler.RemoveClientRelation)
|
||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||
dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache)
|
||||
dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache)
|
||||
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
||||
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||
dev.Get("/consents", devHandler.ListConsents)
|
||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||
dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily)
|
||||
|
||||
// [New] Developer Registration Flow
|
||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||
dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus)
|
||||
dev.Get("/developer-request/status", devHandler.GetDeveloperRequestStatus)
|
||||
dev.Get("/developer-request/list", devHandler.ListDeveloperRequests)
|
||||
dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest)
|
||||
dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest)
|
||||
dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval)
|
||||
|
||||
// Webhook for Kratos courier (HTTP delivery)
|
||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||
|
||||
// Client Logging Route (Standardized & Flattened)
|
||||
api.Post("/client-log", func(c *fiber.Ctx) error {
|
||||
type LogReq struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
var req LogReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.SendStatus(fiber.StatusBadRequest)
|
||||
}
|
||||
if !logger.ShouldAcceptClientLog(appEnv, clientLogDebugFlag, req.Level) {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
level := logger.NormalizeClientLogLevel(req.Level)
|
||||
if level == slog.LevelInfo && logger.ShouldFilterNoisyClientInfo(appEnv, clientLogDebugFlag, req.Message) {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
// Prepare attributes for flattening
|
||||
attrs := []any{
|
||||
slog.String("source", "client"),
|
||||
}
|
||||
sanitizedData := logger.SanitizeClientLogData(req.Data)
|
||||
for k, v := range sanitizedData {
|
||||
// Skip svc if it's already set by the global logger to avoid confusion,
|
||||
// or keep it as client_svc
|
||||
if k == "svc" {
|
||||
attrs = append(attrs, slog.Any("client_svc", v))
|
||||
} else {
|
||||
attrs = append(attrs, slog.Any(k, v))
|
||||
}
|
||||
}
|
||||
slog.Log(c.Context(), level, logger.SanitizeClientLogMessage(req.Message), attrs...)
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
// Start Server
|
||||
port := getEnv("BACKEND_PORT", "3000")
|
||||
slog.Info("Server listening", "port", port)
|
||||
fmt.Println("============================================================")
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
slog.Error("Server failed to start", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func configureWorksmobileClientFromEnv(client *service.WorksmobileHTTPClient) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
client.BaseURL = strings.TrimSpace(getEnv("WORKS_ADMIN_API_BASE_URL", ""))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestOpenAPIDocumentsExternalAPIs(t *testing.T) {
|
||||
data, err := os.ReadFile("../../docs/openapi.yaml")
|
||||
require.NoError(t, err)
|
||||
spec := string(data)
|
||||
var parsed map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(data, &parsed))
|
||||
|
||||
required := []string{
|
||||
"/.well-known/baron-rp-manifest.json:",
|
||||
"/.well-known/baron-rp-manifest.schema.json:",
|
||||
"/api/v1/public/orgchart:",
|
||||
"/api/v1/tenants/registration:",
|
||||
"/api/v1/integrations/org-context:",
|
||||
"/api/v1/admin/api-keys:",
|
||||
"/api/v1/admin/api-keys/{id}:",
|
||||
"/api/v1/admin/api-keys/{id}/secret/rotate:",
|
||||
"ApiKeyUpdateScopesRequest:",
|
||||
"BaronApiKeyId:",
|
||||
"BaronApiKeySecret:",
|
||||
"X-Baron-Key-ID",
|
||||
"X-Baron-Key-Secret",
|
||||
"API Key 인증이 필요한 요청의 header에 자동으로 포함됩니다.",
|
||||
"OrgContextResponse:",
|
||||
}
|
||||
for _, expected := range required {
|
||||
require.Contains(t, spec, expected)
|
||||
}
|
||||
|
||||
require.False(t, strings.Contains(spec, "/api/v1/orgfront/org-context:"))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetEnvFileOrValueReadsSecretFile(t *testing.T) {
|
||||
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "inline-value")
|
||||
|
||||
secretPath := filepath.Join(t.TempDir(), "worksmobile-private-key.pem")
|
||||
want := "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----\n"
|
||||
if err := os.WriteFile(secretPath, []byte(want), 0o600); err != nil {
|
||||
t.Fatalf("failed to write secret file: %v", err)
|
||||
}
|
||||
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", secretPath)
|
||||
|
||||
got, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
||||
if err != nil {
|
||||
t.Fatalf("getEnvFileOrValue returned error: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("secret value = %q, want file content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvFileOrValueFallsBackToRawEnv(t *testing.T) {
|
||||
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "inline-value")
|
||||
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "")
|
||||
|
||||
got, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
||||
if err != nil {
|
||||
t.Fatalf("getEnvFileOrValue returned error: %v", err)
|
||||
}
|
||||
if got != "inline-value" {
|
||||
t.Fatalf("secret value = %q, want raw env value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureWorksmobileClientFromEnvOverridesAPIBaseURL(t *testing.T) {
|
||||
t.Setenv("WORKS_ADMIN_API_BASE_URL", "https://proxy.example.com/works")
|
||||
client := service.NewWorksmobileHTTPClientWithTokens("", "")
|
||||
|
||||
configureWorksmobileClientFromEnv(client)
|
||||
|
||||
if client.BaseURL != "https://proxy.example.com/works" {
|
||||
t.Fatalf("BaseURL = %q, want env override", client.BaseURL)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Baron SSO ReDoc</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="redoc-root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
const path = window.location.pathname;
|
||||
const docsBase = path.replace(/\/redoc\/?$/, "/redoc/");
|
||||
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
|
||||
const openapiUrl = assetBase.replace(/redoc\/$/, "openapi.yaml");
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = assetBase + "redoc.standalone.js";
|
||||
script.onload = () => {
|
||||
if (window.Redoc) {
|
||||
window.Redoc.init(openapiUrl, {}, document.getElementById("redoc-root"));
|
||||
}
|
||||
};
|
||||
script.onerror = (err) => {
|
||||
console.error("ReDoc load failed", err);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Baron SSO Swagger UI</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f7f7f8;
|
||||
}
|
||||
.topbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script>
|
||||
(function () {
|
||||
const path = window.location.pathname;
|
||||
const docsBase = path.replace(/\/docs\/?$/, "/docs/");
|
||||
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
|
||||
|
||||
const css = document.createElement("link");
|
||||
css.rel = "stylesheet";
|
||||
css.href = assetBase + "swagger-ui.css";
|
||||
document.head.appendChild(css);
|
||||
|
||||
const loadScript = (src) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
loadScript(assetBase + "swagger-ui-bundle.js"),
|
||||
loadScript(assetBase + "swagger-ui-standalone-preset.js"),
|
||||
])
|
||||
.then(() => {
|
||||
const openapiUrl = assetBase.replace(/docs\/$/, "openapi.yaml");
|
||||
SwaggerUIBundle({
|
||||
url: openapiUrl,
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset,
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Swagger UI load failed", err);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
|
||||
module baron-sso-backend
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
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/go-jose/go-jose/v4 v4.1.3
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/ClickHouse/ch-go v0.69.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.2+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
|
||||
github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 h1:2Lnd3ZNTyWpFJJM55y0mP0aESovm+vFuFEwLijucUL8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18/go.mod h1:BLwHw6wdkA6NfnW/cFaVcvpwdIXHLAkpe6nsLF9BVww=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
|
||||
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
@@ -0,0 +1,203 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SuperAdminIdentityAdmin interface {
|
||||
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||
}
|
||||
|
||||
type SuperAdminStore interface {
|
||||
FindUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
CreateUser(ctx context.Context, user *domain.User) error
|
||||
UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error)
|
||||
EnqueueSuperAdminRelation(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type EnsureSuperAdminOptions struct {
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
Source string
|
||||
UpdatePassword bool
|
||||
}
|
||||
|
||||
type EnsureSuperAdminResult struct {
|
||||
Email string
|
||||
IdentityID string
|
||||
LocalUserID string
|
||||
IdentityCreated bool
|
||||
PasswordUpdated bool
|
||||
LocalUserCreated bool
|
||||
LocalUserUpdated bool
|
||||
KetoRelationQueued bool
|
||||
}
|
||||
|
||||
func EnsureSuperAdmin(ctx context.Context, identityAdmin SuperAdminIdentityAdmin, store SuperAdminStore, opts EnsureSuperAdminOptions) (EnsureSuperAdminResult, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(opts.Email))
|
||||
name := strings.TrimSpace(opts.Name)
|
||||
if name == "" {
|
||||
name = "System Admin"
|
||||
}
|
||||
source := strings.TrimSpace(opts.Source)
|
||||
if source == "" {
|
||||
source = "admin_cli"
|
||||
}
|
||||
result := EnsureSuperAdminResult{Email: email}
|
||||
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return result, fmt.Errorf("invalid admin email: %w", err)
|
||||
}
|
||||
if identityAdmin == nil {
|
||||
return result, errors.New("identity admin is required")
|
||||
}
|
||||
if store == nil {
|
||||
return result, errors.New("super admin store is required")
|
||||
}
|
||||
|
||||
identityID, err := identityAdmin.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("find admin identity: %w", err)
|
||||
}
|
||||
if identityID == "" {
|
||||
if strings.TrimSpace(opts.Password) == "" {
|
||||
return result, errors.New("admin password is required to create identity")
|
||||
}
|
||||
identityID, err = identityAdmin.CreateUser(ctx, buildSuperAdminBrokerUser(email, name), opts.Password)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create admin identity: %w", err)
|
||||
}
|
||||
result.IdentityCreated = true
|
||||
} else if opts.UpdatePassword {
|
||||
if strings.TrimSpace(opts.Password) == "" {
|
||||
return result, errors.New("admin password is required to update identity password")
|
||||
}
|
||||
if err := identityAdmin.UpdateIdentityPassword(ctx, identityID, opts.Password); err != nil {
|
||||
return result, fmt.Errorf("update admin identity password: %w", err)
|
||||
}
|
||||
result.PasswordUpdated = true
|
||||
}
|
||||
result.IdentityID = identityID
|
||||
|
||||
user, err := store.FindUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("find local admin user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
if identityID == "" {
|
||||
return result, errors.New("identity id is required to create local admin user")
|
||||
}
|
||||
user = &domain.User{
|
||||
ID: identityID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Role: domain.RoleSuperAdmin,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Metadata: domain.JSONMap{
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
if err := store.CreateUser(ctx, user); err != nil {
|
||||
return result, fmt.Errorf("create local admin user: %w", err)
|
||||
}
|
||||
result.LocalUserCreated = true
|
||||
} else if domain.NormalizeRole(user.Role) != domain.RoleSuperAdmin || user.Status != domain.UserStatusActive || (name != "" && user.Name != name) {
|
||||
user, err = store.UpdateUserSuperAdmin(ctx, user.ID, name)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("update local admin user: %w", err)
|
||||
}
|
||||
result.LocalUserUpdated = true
|
||||
}
|
||||
result.LocalUserID = user.ID
|
||||
|
||||
if err := store.EnqueueSuperAdminRelation(ctx, user.ID); err != nil {
|
||||
return result, fmt.Errorf("enqueue super admin keto relation: %w", err)
|
||||
}
|
||||
result.KetoRelationQueued = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
|
||||
return &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: name,
|
||||
PhoneNumber: "",
|
||||
Attributes: map[string]any{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"grade": "",
|
||||
"role": domain.RoleSuperAdmin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type gormSuperAdminStore struct {
|
||||
db *gorm.DB
|
||||
outbox repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func NewGormSuperAdminStore(db *gorm.DB, outbox repository.KetoOutboxRepository) SuperAdminStore {
|
||||
return &gormSuperAdminStore{db: db, outbox: outbox}
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
var user domain.User
|
||||
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
|
||||
return s.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
|
||||
updates := map[string]any{
|
||||
"role": domain.RoleSuperAdmin,
|
||||
"status": domain.UserStatusActive,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
updates["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&domain.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
|
||||
if s.outbox == nil {
|
||||
return nil
|
||||
}
|
||||
return s.outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
identityAdmin := &fakeSuperAdminIdentityAdmin{createdID: "identity-1"}
|
||||
store := &fakeSuperAdminStore{}
|
||||
|
||||
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
|
||||
Email: "new-admin@example.com",
|
||||
Password: "Password!123",
|
||||
Name: "New Admin",
|
||||
Source: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
|
||||
}
|
||||
if !result.IdentityCreated {
|
||||
t.Fatal("identity must be created")
|
||||
}
|
||||
if !result.LocalUserCreated {
|
||||
t.Fatal("local user must be created")
|
||||
}
|
||||
if result.IdentityID != "identity-1" {
|
||||
t.Fatalf("identity ID = %q, want identity-1", result.IdentityID)
|
||||
}
|
||||
if store.user == nil {
|
||||
t.Fatal("local user was not stored")
|
||||
}
|
||||
if store.user.Email != "new-admin@example.com" {
|
||||
t.Fatalf("local user email = %q", store.user.Email)
|
||||
}
|
||||
if store.user.Role != domain.RoleSuperAdmin {
|
||||
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
|
||||
}
|
||||
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:identity-1" {
|
||||
t.Fatalf("keto subjects = %#v, want User:identity-1", store.ketoSubjects)
|
||||
}
|
||||
if identityAdmin.createdUser == nil || identityAdmin.createdUser.Attributes["role"] != domain.RoleSuperAdmin {
|
||||
t.Fatalf("created identity attributes = %#v", identityAdmin.createdUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
identityAdmin := &fakeSuperAdminIdentityAdmin{existingID: "identity-1"}
|
||||
store := &fakeSuperAdminStore{
|
||||
user: &domain.User{
|
||||
ID: "local-user-1",
|
||||
Email: "existing@example.com",
|
||||
Name: "Existing",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusPreboarding,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
|
||||
Email: "existing@example.com",
|
||||
Password: "Password!123",
|
||||
Name: "Existing Admin",
|
||||
Source: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
|
||||
}
|
||||
if result.IdentityCreated {
|
||||
t.Fatal("existing identity must not be recreated")
|
||||
}
|
||||
if !result.LocalUserUpdated {
|
||||
t.Fatal("local user must be promoted")
|
||||
}
|
||||
if store.user.Role != domain.RoleSuperAdmin {
|
||||
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
|
||||
}
|
||||
if store.user.Status != domain.UserStatusActive {
|
||||
t.Fatalf("local user status = %q, want %q", store.user.Status, domain.UserStatusActive)
|
||||
}
|
||||
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:local-user-1" {
|
||||
t.Fatalf("keto subjects = %#v, want User:local-user-1", store.ketoSubjects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSuperAdminRequiresPasswordForNewIdentity(t *testing.T) {
|
||||
_, err := EnsureSuperAdmin(context.Background(), &fakeSuperAdminIdentityAdmin{}, &fakeSuperAdminStore{}, EnsureSuperAdminOptions{
|
||||
Email: "new-admin@example.com",
|
||||
Name: "New Admin",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSuperAdminIdentityAdmin struct {
|
||||
existingID string
|
||||
createdID string
|
||||
createdUser *domain.BrokerUser
|
||||
createdSecret string
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminIdentityAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
return f.existingID, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminIdentityAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
if f.createdID == "" {
|
||||
return "", errors.New("created id is not configured")
|
||||
}
|
||||
f.createdUser = user
|
||||
f.createdSecret = password
|
||||
return f.createdID, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminIdentityAdmin) UpdateIdentityPassword(ctx context.Context, identityID string, newPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSuperAdminStore struct {
|
||||
user *domain.User
|
||||
ketoSubjects []string
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
|
||||
copied := *user
|
||||
f.user = &copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
f.user.Role = domain.RoleSuperAdmin
|
||||
f.user.Status = domain.UserStatusActive
|
||||
if name != "" {
|
||||
f.user.Name = name
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
|
||||
f.ketoSubjects = append(f.ketoSubjects, "User:"+userID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Run executes the application bootstrap logic (migrations, seeding, etc.)
|
||||
func Run(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Starting application bootstrap...")
|
||||
|
||||
// 1. Auto Migration
|
||||
if err := migrateSchemas(db); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. Seed Tenants
|
||||
if err := SeedTenants(db); err != nil {
|
||||
return fmt.Errorf("tenant seeding failed: %w", err)
|
||||
}
|
||||
|
||||
// 3. Normalize staging seed/read-model data
|
||||
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
|
||||
return fmt.Errorf("legacy user status canonicalization failed: %w", err)
|
||||
}
|
||||
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
||||
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
|
||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSchemas(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dropLegacyUserCompanyColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add all domain models here
|
||||
return db.AutoMigrate(
|
||||
&domain.Tenant{},
|
||||
&domain.TenantDomain{},
|
||||
&domain.User{},
|
||||
&domain.UserLoginID{},
|
||||
&domain.UserProjectionState{},
|
||||
&domain.UserGroup{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
&domain.ClientSecret{},
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.RPUsageEvent{},
|
||||
&domain.WorksmobileOutbox{},
|
||||
&domain.WorksmobileResourceMapping{},
|
||||
&domain.SharedLink{},
|
||||
&domain.DeveloperRequest{},
|
||||
&domain.RPUserMetadata{},
|
||||
&domain.SystemSetting{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
|
||||
func CanonicalizeLegacyUserStatuses(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable(&domain.User{}) {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]string{
|
||||
"inactive": domain.UserStatusPreboarding,
|
||||
"leave_of_absence": domain.UserStatusTemporaryLeave,
|
||||
"baron_only": domain.UserStatusBaronGuest,
|
||||
}
|
||||
for legacy, canonical := range updates {
|
||||
if err := db.Model(&domain.User{}).
|
||||
Where("status = ?", legacy).
|
||||
Update("status", canonical).Error; err != nil {
|
||||
return fmt.Errorf("failed to canonicalize users.status %s to %s: %w", legacy, canonical, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyUserCompanyColumns(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&domain.User{}) {
|
||||
return nil
|
||||
}
|
||||
for _, column := range []string{"company_code", "company_codes"} {
|
||||
if !db.Migrator().HasColumn(&domain.User{}, column) {
|
||||
continue
|
||||
}
|
||||
if err := db.Migrator().DropColumn(&domain.User{}, column); err != nil {
|
||||
return fmt.Errorf("failed to drop legacy users.%s column: %w", column, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyTenantDomainUniqueIndex(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&domain.TenantDomain{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasIndex(&domain.TenantDomain{}, "idx_tenant_domains_domain") {
|
||||
return nil
|
||||
}
|
||||
if err := db.Migrator().DropIndex(&domain.TenantDomain{}, "idx_tenant_domains_domain"); err != nil {
|
||||
return fmt.Errorf("failed to drop legacy tenant domain unique index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SyncKetoRelations synchronizes all existing DB users, tenants and RPs to Ory Keto via Outbox.
|
||||
// This ensures data consistency for existing data when ReBAC is introduced.
|
||||
func SyncKetoRelations(db *gorm.DB, outbox repository.KetoOutboxRepository) error {
|
||||
slog.Info("🚀 Starting Keto ReBAC relation synchronization (via Outbox)...")
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Sync All Tenants
|
||||
var tenants []domain.Tenant
|
||||
if err := db.Find(&tenants).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("Syncing tenants to Keto Outbox", "count", len(tenants))
|
||||
for _, t := range tenants {
|
||||
// Global Super Admin access to every tenant
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "admins",
|
||||
Subject: "System:global#super_admins",
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
if t.ParentID != nil {
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *t.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync All RelyingParties (if needed)
|
||||
// Note: We'll need a way to list them from Hydra or local DB if we had them.
|
||||
// Assuming they are in a table domain.RelyingParty (though it was removed, let's see)
|
||||
// Actually, the comment said SSOT is Hydra. But we might have them in a local table for metadata.
|
||||
// If not, we skip for now or fetch from Hydra.
|
||||
|
||||
// 3. Sync All Users Roles and Tenant Memberships
|
||||
var users []domain.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("Syncing users to Keto Outbox", "count", len(users))
|
||||
for _, u := range users {
|
||||
// Tenant Membership
|
||||
if u.TenantID != nil {
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + u.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// Roles
|
||||
role := domain.NormalizeRole(u.Role)
|
||||
if role == domain.RoleSuperAdmin {
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + u.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("✅ Keto ReBAC synchronization items added to Outbox.")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeedAdminIdentity creates the initial admin identity in the configured IDP.
|
||||
// Returns the Kratos Identity ID and error.
|
||||
func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
|
||||
if idp == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminEmail == "" || adminPassword == "" {
|
||||
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
|
||||
if adminName == "" {
|
||||
adminName = "System Admin"
|
||||
}
|
||||
|
||||
user := &domain.BrokerUser{
|
||||
Email: adminEmail,
|
||||
Name: adminName,
|
||||
PhoneNumber: "",
|
||||
Attributes: map[string]any{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"grade": "",
|
||||
"role": "super_admin", // Explicitly set role for Kratos traits
|
||||
},
|
||||
}
|
||||
|
||||
// Retry logic for Kratos connection
|
||||
maxRetries := 5
|
||||
var err error
|
||||
var identityID string
|
||||
|
||||
for i := range maxRetries {
|
||||
identityID, err = idp.CreateUser(user, adminPassword)
|
||||
if err == nil {
|
||||
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name(), "id", identityID)
|
||||
return identityID, nil
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
slog.Info("[Bootstrap] Admin identity already exists in IDP. Attempting to retrieve ID...", "email", adminEmail)
|
||||
// Try to sign in to get the identity ID
|
||||
authInfo, err := idp.SignIn(adminEmail, adminPassword)
|
||||
if err == nil && authInfo != nil {
|
||||
slog.Info("[Bootstrap] Retrieved existing admin identity ID", "id", authInfo.Subject)
|
||||
return authInfo.Subject, nil
|
||||
}
|
||||
slog.Warn("[Bootstrap] Failed to retrieve existing admin identity ID via SignIn", "error", err)
|
||||
return "", nil // Return nil error to avoid stopping bootstrap, but ID is missing
|
||||
}
|
||||
|
||||
slog.Warn("[Bootstrap] Failed to seed admin identity (retrying...)",
|
||||
"attempt", i+1,
|
||||
"max_retries", maxRetries,
|
||||
"error", err,
|
||||
)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SyncAdminRole updates the role of the admin user in the local DB.
|
||||
// It ensures the admin user exists in the local DB with the correct Kratos ID.
|
||||
func SyncAdminRole(db *gorm.DB, kratosID string) error {
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
if adminEmail == "" {
|
||||
slog.Warn("[Bootstrap] ADMIN_EMAIL not set. Skipping admin role sync.")
|
||||
return nil
|
||||
}
|
||||
|
||||
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
|
||||
if adminName == "" {
|
||||
adminName = "System Admin"
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
var user domain.User
|
||||
if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if kratosID == "" {
|
||||
slog.Warn("[Bootstrap] Admin user not found in local DB and Kratos ID is missing. Cannot create local user.", "email", adminEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create new admin user in local DB
|
||||
newUser := domain.User{
|
||||
ID: kratosID,
|
||||
Email: adminEmail,
|
||||
Name: adminName,
|
||||
Role: domain.RoleSuperAdmin,
|
||||
Status: "active",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Metadata: domain.JSONMap{"source": "bootstrap_seed"},
|
||||
}
|
||||
if err := db.Create(&newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("[Bootstrap] Created admin user in local DB", "email", adminEmail, "id", kratosID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Update role if needed
|
||||
updates := map[string]any{}
|
||||
if user.Role != domain.RoleSuperAdmin {
|
||||
updates["role"] = domain.RoleSuperAdmin
|
||||
}
|
||||
// Also ensure ID matches if it was somehow different (though changing PK is hard, at least log it)
|
||||
if kratosID != "" && user.ID != kratosID {
|
||||
slog.Warn("[Bootstrap] Admin user exists but ID mismatch with Kratos", "local_id", user.ID, "kratos_id", kratosID)
|
||||
// We generally don't change UUID PKs, just warn.
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&user).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("[Bootstrap] Updated admin user role to super_admin", "email", adminEmail)
|
||||
} else {
|
||||
slog.Info("[Bootstrap] Admin user already has super_admin role", "email", adminEmail)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const seedTenantCSVPathEnv = "SEED_TENANT_CSV_PATH"
|
||||
|
||||
var seedTenantCSVPathCandidates = []string{
|
||||
"adminfront/seed-tenant.csv",
|
||||
"../adminfront/seed-tenant.csv",
|
||||
"../../adminfront/seed-tenant.csv",
|
||||
"../../../adminfront/seed-tenant.csv",
|
||||
"/app/adminfront/seed-tenant.csv",
|
||||
}
|
||||
|
||||
type InitialTenantConfig struct {
|
||||
TenantID string
|
||||
Name string
|
||||
Slug string
|
||||
Type string
|
||||
ParentSlug string
|
||||
Description string
|
||||
Domains []string
|
||||
Config domain.JSONMap
|
||||
}
|
||||
|
||||
func SeedTenants(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Checking initial tenant seed...")
|
||||
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(configs) == 0 {
|
||||
return errors.New("seed tenant csv has no tenant rows")
|
||||
}
|
||||
|
||||
existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
missingConfigs := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs)
|
||||
if len(missingConfigs) == 0 {
|
||||
slog.Info("[Bootstrap] Tenant seed skipped because all seed slugs already exist", "count", len(configs))
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"[Bootstrap] Tenant seed will create missing seed tenants",
|
||||
"total", len(configs),
|
||||
"missing", len(missingConfigs),
|
||||
"existing", len(configs)-len(missingConfigs),
|
||||
)
|
||||
return seedTenantConfigs(db, missingConfigs)
|
||||
}
|
||||
|
||||
func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) {
|
||||
var tenants []domain.Tenant
|
||||
if err := db.Select("id", "slug").Find(&tenants).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("load existing tenants before seed: %w", err)
|
||||
}
|
||||
|
||||
slugs := make(map[string]bool, len(tenants))
|
||||
ids := make(map[string]bool, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
slug := strings.TrimSpace(strings.ToLower(tenant.Slug))
|
||||
if slug != "" {
|
||||
slugs[slug] = true
|
||||
}
|
||||
id := strings.TrimSpace(strings.ToLower(tenant.ID))
|
||||
if id != "" {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
return slugs, ids, nil
|
||||
}
|
||||
|
||||
func filterMissingSeedTenantConfigs(configs []InitialTenantConfig, existingSlugs map[string]bool, existingIDs map[string]bool) []InitialTenantConfig {
|
||||
filtered := make([]InitialTenantConfig, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
slug := strings.TrimSpace(strings.ToLower(config.Slug))
|
||||
id := strings.TrimSpace(strings.ToLower(config.TenantID))
|
||||
if slug == "" || existingSlugs[slug] || (id != "" && existingIDs[id]) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, config)
|
||||
existingSlugs[slug] = true
|
||||
if id != "" {
|
||||
existingIDs[id] = true
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
|
||||
slog.Info("[Bootstrap] Seeding initial tenants from CSV...", "count", len(configs))
|
||||
repo := repository.NewTenantRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
outboxRepo := repository.NewKetoOutboxRepository(db)
|
||||
svc := service.NewTenantService(repo, userRepo, userGroupRepo, outboxRepo)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, config := range orderSeedTenantConfigsByParentSlug(configs) {
|
||||
tenantType := config.Type
|
||||
if tenantType == "" {
|
||||
tenantType = domain.TenantTypeCompany
|
||||
}
|
||||
|
||||
var parentID *string
|
||||
if config.ParentSlug != "" {
|
||||
parent, err := repo.FindBySlug(ctx, config.ParentSlug)
|
||||
if err != nil || parent == nil {
|
||||
if err == nil {
|
||||
err = errors.New("parent tenant not found")
|
||||
}
|
||||
slog.Error("Failed to resolve parent tenant for seed", "slug", config.Slug, "parentSlug", config.ParentSlug, "error", err)
|
||||
return fmt.Errorf("resolve parent tenant %q for seed %q: %w", config.ParentSlug, config.Slug, err)
|
||||
}
|
||||
parentID = &parent.ID
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] Creating seed tenant", "name", config.Name, "slug", config.Slug)
|
||||
var tenant *domain.Tenant
|
||||
var err error
|
||||
if config.TenantID != "" {
|
||||
tenant, err = createSeedTenant(ctx, repo, outboxRepo, config, tenantType, parentID)
|
||||
} else {
|
||||
tenant, err = svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||
return err
|
||||
}
|
||||
tenant.Status = domain.TenantStatusActive
|
||||
if len(config.Config) > 0 {
|
||||
tenant.Config = config.Config
|
||||
}
|
||||
if err := db.Save(tenant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSeedTenantConfigs() ([]InitialTenantConfig, error) {
|
||||
path, err := findSeedTenantCSVPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open seed tenant csv %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
configs, err := parseSeedTenantCSV(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse seed tenant csv %q: %w", path, err)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func SeedTenantSlugSet() (map[string]bool, error) {
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slugs := make(map[string]bool, len(configs))
|
||||
for _, config := range configs {
|
||||
slug := strings.TrimSpace(strings.ToLower(config.Slug))
|
||||
if slug != "" {
|
||||
slugs[slug] = true
|
||||
}
|
||||
}
|
||||
return slugs, nil
|
||||
}
|
||||
|
||||
func IsSeedTenantSlug(slug string) bool {
|
||||
normalized := strings.TrimSpace(strings.ToLower(slug))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
slugs, err := SeedTenantSlugSet()
|
||||
if err != nil {
|
||||
slog.Warn("[Bootstrap] Failed to load seed tenant slug set", "error", err)
|
||||
return false
|
||||
}
|
||||
return slugs[normalized]
|
||||
}
|
||||
|
||||
func findSeedTenantCSVPath() (string, error) {
|
||||
if configured := strings.TrimSpace(os.Getenv(seedTenantCSVPathEnv)); configured != "" {
|
||||
return configured, nil
|
||||
}
|
||||
|
||||
for _, candidate := range seedTenantCSVPathCandidates {
|
||||
cleaned := filepath.Clean(candidate)
|
||||
if _, err := os.Stat(cleaned); err == nil {
|
||||
return cleaned, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("seed tenant csv not found; set %s or add adminfront/seed-tenant.csv", seedTenantCSVPathEnv)
|
||||
}
|
||||
|
||||
func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to read csv")
|
||||
}
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
reader := csv.NewReader(bytes.NewReader(data))
|
||||
reader.FieldsPerRecord = -1
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid csv: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, errors.New("csv is empty")
|
||||
}
|
||||
|
||||
header := seedTenantCSVHeaderIndex(rows[0])
|
||||
for _, key := range []string{"name", "type", "slug"} {
|
||||
if _, ok := header[key]; !ok {
|
||||
return nil, fmt.Errorf("missing required column: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
configs := make([]InitialTenantConfig, 0, len(rows)-1)
|
||||
for i, row := range rows[1:] {
|
||||
if seedTenantCSVRowIsEmpty(row) {
|
||||
continue
|
||||
}
|
||||
|
||||
name := seedTenantCSVValue(row, header, "name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("row %d: name is required", i+2)
|
||||
}
|
||||
|
||||
tenantType := normalizeSeedTenantType(seedTenantCSVValue(row, header, "type"))
|
||||
if tenantType == "" {
|
||||
return nil, fmt.Errorf("row %d: invalid tenant type", i+2)
|
||||
}
|
||||
|
||||
slug := utils.GenerateSlug(seedTenantCSVValue(row, header, "slug"))
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("row %d: slug is required", i+2)
|
||||
}
|
||||
|
||||
config, err := seedTenantCSVRecordConfig(row, header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d: %w", i+2, err)
|
||||
}
|
||||
|
||||
configs = append(configs, InitialTenantConfig{
|
||||
TenantID: seedTenantCSVValue(row, header, "tenant_id"),
|
||||
Name: name,
|
||||
Type: tenantType,
|
||||
ParentSlug: seedTenantCSVValue(row, header, "parent_tenant_slug"),
|
||||
Slug: slug,
|
||||
Description: seedTenantCSVValue(row, header, "memo"),
|
||||
Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")),
|
||||
Config: config,
|
||||
})
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func seedTenantCSVHeaderIndex(header []string) map[string]int {
|
||||
index := make(map[string]int, len(header))
|
||||
aliases := map[string]string{
|
||||
"id": "tenant_id",
|
||||
"tenantid": "tenant_id",
|
||||
"tenant_id": "tenant_id",
|
||||
"name": "name",
|
||||
"type": "type",
|
||||
"parenttenantslug": "parent_tenant_slug",
|
||||
"parent_tenant_slug": "parent_tenant_slug",
|
||||
"parent_slug": "parent_tenant_slug",
|
||||
"slug": "slug",
|
||||
"memo": "memo",
|
||||
"description": "memo",
|
||||
"email-domain": "email_domain",
|
||||
"emaildomain": "email_domain",
|
||||
"email_domain": "email_domain",
|
||||
"domain": "email_domain",
|
||||
"domains": "email_domain",
|
||||
"visibility": "visibility",
|
||||
"public_setting": "visibility",
|
||||
"publicsetting": "visibility",
|
||||
"org_unit_type": "org_unit_type",
|
||||
"orgunittype": "org_unit_type",
|
||||
"organization_type": "org_unit_type",
|
||||
"organizationtype": "org_unit_type",
|
||||
"worksmobile": "worksmobile_sync",
|
||||
"worksmobilesync": "worksmobile_sync",
|
||||
"worksmobile_sync": "worksmobile_sync",
|
||||
"works_sync": "worksmobile_sync",
|
||||
"works": "worksmobile_sync",
|
||||
}
|
||||
for i, column := range header {
|
||||
key := strings.ToLower(strings.TrimSpace(column))
|
||||
key = strings.ReplaceAll(key, " ", "_")
|
||||
if canonical, ok := aliases[key]; ok {
|
||||
index[canonical] = i
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func seedTenantCSVValue(row []string, header map[string]int, key string) string {
|
||||
idx, ok := header[key]
|
||||
if !ok || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
func seedTenantCSVRecordConfig(row []string, header map[string]int) (domain.JSONMap, error) {
|
||||
config := domain.JSONMap{}
|
||||
visibility := strings.TrimSpace(seedTenantCSVValue(row, header, "visibility"))
|
||||
if visibility != "" {
|
||||
normalizedVisibility, err := normalizeSeedTenantVisibility(visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config["visibility"] = normalizedVisibility
|
||||
}
|
||||
orgUnitType := strings.TrimSpace(seedTenantCSVValue(row, header, "org_unit_type"))
|
||||
if orgUnitType != "" {
|
||||
if !isAllowedSeedTenantOrgUnitType(orgUnitType) {
|
||||
return nil, errors.New("orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속")
|
||||
}
|
||||
config["orgUnitType"] = orgUnitType
|
||||
}
|
||||
if worksmobileSync := strings.TrimSpace(seedTenantCSVValue(row, header, "worksmobile_sync")); worksmobileSync != "" {
|
||||
excluded, err := normalizeSeedTenantWorksmobileExcluded(worksmobileSync)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config["worksmobileExcluded"] = excluded
|
||||
}
|
||||
if len(config) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func normalizeSeedTenantWorksmobileExcluded(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
|
||||
return false, nil
|
||||
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("worksmobile_sync must be yes or no")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSeedTenantVisibility(value string) (string, error) {
|
||||
visibility := strings.ToLower(strings.TrimSpace(value))
|
||||
if visibility == "" || visibility == "public" {
|
||||
return "public", nil
|
||||
}
|
||||
if visibility != "internal" && visibility != "private" {
|
||||
return "", errors.New("visibility must be public, internal, or private")
|
||||
}
|
||||
return visibility, nil
|
||||
}
|
||||
|
||||
func isAllowedSeedTenantOrgUnitType(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func seedTenantCSVRowIsEmpty(row []string) bool {
|
||||
for _, value := range row {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeSeedTenantType(value string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case domain.TenantTypePersonal:
|
||||
return domain.TenantTypePersonal
|
||||
case domain.TenantTypeCompany:
|
||||
return domain.TenantTypeCompany
|
||||
case domain.TenantTypeCompanyGroup:
|
||||
return domain.TenantTypeCompanyGroup
|
||||
case domain.TenantTypeOrganization:
|
||||
return domain.TenantTypeOrganization
|
||||
case domain.TenantTypeUserGroup:
|
||||
return domain.TenantTypeUserGroup
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func splitSeedTenantCSVDomains(value string) []string {
|
||||
value = strings.ReplaceAll(value, "\n", ";")
|
||||
value = strings.ReplaceAll(value, ",", ";")
|
||||
parts := strings.Split(value, ";")
|
||||
domains := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
domainName := strings.ToLower(strings.TrimSpace(part))
|
||||
if domainName == "" || seen[domainName] {
|
||||
continue
|
||||
}
|
||||
seen[domainName] = true
|
||||
domains = append(domains, domainName)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func orderSeedTenantConfigsByParentSlug(configs []InitialTenantConfig) []InitialTenantConfig {
|
||||
bySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||
for _, config := range configs {
|
||||
bySlug[strings.ToLower(config.Slug)] = config
|
||||
}
|
||||
|
||||
ordered := make([]InitialTenantConfig, 0, len(configs))
|
||||
visited := make(map[string]bool, len(configs))
|
||||
var visit func(config InitialTenantConfig)
|
||||
visit = func(config InitialTenantConfig) {
|
||||
key := strings.ToLower(config.Slug)
|
||||
if visited[key] {
|
||||
return
|
||||
}
|
||||
if config.ParentSlug != "" {
|
||||
if parent, ok := bySlug[strings.ToLower(config.ParentSlug)]; ok {
|
||||
visit(parent)
|
||||
}
|
||||
}
|
||||
visited[key] = true
|
||||
ordered = append(ordered, config)
|
||||
}
|
||||
|
||||
for _, config := range configs {
|
||||
visit(config)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func createSeedTenant(
|
||||
ctx context.Context,
|
||||
repo repository.TenantRepository,
|
||||
outboxRepo repository.KetoOutboxRepository,
|
||||
config InitialTenantConfig,
|
||||
tenantType string,
|
||||
parentID *string,
|
||||
) (*domain.Tenant, error) {
|
||||
tenant := &domain.Tenant{
|
||||
ID: config.TenantID,
|
||||
Type: tenantType,
|
||||
Name: config.Name,
|
||||
Slug: config.Slug,
|
||||
Description: config.Description,
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: parentID,
|
||||
Config: config.Config,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, tenant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "admins",
|
||||
Subject: "System:global#super_admins",
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenant.ParentID != nil {
|
||||
if err := outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *tenant.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, domainName := range config.Domains {
|
||||
if err := repo.AddDomain(ctx, tenant.ID, domainName, true); err != nil {
|
||||
slog.Error("Failed to add domain to seeded tenant", "tenant", config.Slug, "domain", domainName, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return repo.FindBySlug(ctx, config.Slug)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
gorm_postgres "gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||
}
|
||||
|
||||
expected := []struct {
|
||||
name string
|
||||
slug string
|
||||
tenantType string
|
||||
parentSlug string
|
||||
domains []string
|
||||
}{
|
||||
{
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
tenantType: domain.TenantTypeCompanyGroup,
|
||||
},
|
||||
{
|
||||
name: "삼안",
|
||||
slug: "saman",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"samaneng.com"},
|
||||
},
|
||||
{
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"hanmaceng.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "총괄기획&기술개발센터",
|
||||
slug: "gpdtdc",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"baroncs.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "바론그룹",
|
||||
slug: "baron-group",
|
||||
tenantType: domain.TenantTypeCompanyGroup,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"brsw.kr"},
|
||||
},
|
||||
{
|
||||
name: "(주)장헌",
|
||||
slug: "jangheon",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"jangheon.com"},
|
||||
},
|
||||
{
|
||||
name: "장헌산업",
|
||||
slug: "jangheon-sanup",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"jangheon.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "한라산업개발",
|
||||
slug: "halla",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"hallasanup.com"},
|
||||
},
|
||||
{
|
||||
name: "(주)피티씨",
|
||||
slug: "ptc",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"pre-cast.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
tenantType: domain.TenantTypePersonal,
|
||||
},
|
||||
}
|
||||
|
||||
if len(configs) < len(expected) {
|
||||
t.Fatalf("expected at least %d seed tenants, got %d", len(expected), len(configs))
|
||||
}
|
||||
|
||||
wantFamilyChildOrder := []string{
|
||||
"gpdtdc",
|
||||
"saman",
|
||||
"hanmac",
|
||||
"baron-group",
|
||||
"halla",
|
||||
}
|
||||
policyFamilyChildSlugs := map[string]bool{}
|
||||
for _, slug := range wantFamilyChildOrder {
|
||||
policyFamilyChildSlugs[slug] = true
|
||||
}
|
||||
gotFamilyChildOrder := make([]string, 0, len(wantFamilyChildOrder))
|
||||
for _, config := range configs {
|
||||
if config.ParentSlug == "hanmac-family" && policyFamilyChildSlugs[config.Slug] {
|
||||
gotFamilyChildOrder = append(gotFamilyChildOrder, config.Slug)
|
||||
}
|
||||
}
|
||||
if len(gotFamilyChildOrder) != len(wantFamilyChildOrder) {
|
||||
t.Fatalf("hanmac-family child order = %#v, want %#v", gotFamilyChildOrder, wantFamilyChildOrder)
|
||||
}
|
||||
for i, wantSlug := range wantFamilyChildOrder {
|
||||
if gotFamilyChildOrder[i] != wantSlug {
|
||||
t.Fatalf("hanmac-family child order[%d] = %q, want %q", i, gotFamilyChildOrder[i], wantSlug)
|
||||
}
|
||||
}
|
||||
|
||||
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||
for _, config := range configs {
|
||||
configBySlug[config.Slug] = config
|
||||
}
|
||||
|
||||
for _, want := range expected {
|
||||
got, ok := configBySlug[want.slug]
|
||||
if !ok {
|
||||
t.Fatalf("tenant slug %q not found in seed configs", want.slug)
|
||||
}
|
||||
if got.Name != want.name {
|
||||
t.Fatalf("tenant[%s] name = %q, want %q", want.slug, got.Name, want.name)
|
||||
}
|
||||
if got.Slug != want.slug {
|
||||
t.Fatalf("tenant[%s] slug = %q, want %q", want.slug, got.Slug, want.slug)
|
||||
}
|
||||
if got.Type != want.tenantType {
|
||||
t.Fatalf("tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
|
||||
}
|
||||
if got.ParentSlug != want.parentSlug {
|
||||
t.Fatalf("tenant[%s] parent slug = %q, want %q", want.slug, got.ParentSlug, want.parentSlug)
|
||||
}
|
||||
if len(got.Domains) != len(want.domains) {
|
||||
t.Fatalf("tenant[%s] domains = %#v, want %#v", want.slug, got.Domains, want.domains)
|
||||
}
|
||||
for j, wantDomain := range want.domains {
|
||||
if got.Domains[j] != wantDomain {
|
||||
t.Fatalf("tenant[%s] domain[%d] = %q, want %q", want.slug, j, got.Domains[j], wantDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
|
||||
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
|
||||
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seed-tenant.csv")
|
||||
csv := "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
|
||||
"Root,COMPANY_GROUP,,root,Root memo,,,,\n" +
|
||||
"Child,USER_GROUP,root,child,Child memo,child.example.com,private,팀,no\n"
|
||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||
t.Fatalf("failed to write seed csv: %v", err)
|
||||
}
|
||||
t.Setenv(seedTenantCSVPathEnv, path)
|
||||
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||
}
|
||||
if len(configs) != 2 {
|
||||
t.Fatalf("expected 2 configs, got %d", len(configs))
|
||||
}
|
||||
if configs[1].ParentSlug != "root" {
|
||||
t.Fatalf("child parent slug = %q, want root", configs[1].ParentSlug)
|
||||
}
|
||||
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
|
||||
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
|
||||
}
|
||||
if configs[1].Config["visibility"] != "private" {
|
||||
t.Fatalf("child visibility = %#v, want private", configs[1].Config["visibility"])
|
||||
}
|
||||
if configs[1].Config["orgUnitType"] != "팀" {
|
||||
t.Fatalf("child orgUnitType = %#v, want 팀", configs[1].Config["orgUnitType"])
|
||||
}
|
||||
if configs[1].Config["worksmobileExcluded"] != true {
|
||||
t.Fatalf("child worksmobileExcluded = %#v, want true", configs[1].Config["worksmobileExcluded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTenantCSVDefinesMHDAsPrivateUserGroup(t *testing.T) {
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||
}
|
||||
|
||||
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||
for _, config := range configs {
|
||||
configBySlug[config.Slug] = config
|
||||
}
|
||||
|
||||
mhd, ok := configBySlug["mhd"]
|
||||
if !ok {
|
||||
t.Fatal("mhd seed tenant not found")
|
||||
}
|
||||
if mhd.Type != domain.TenantTypeUserGroup {
|
||||
t.Fatalf("mhd type = %q, want %q", mhd.Type, domain.TenantTypeUserGroup)
|
||||
}
|
||||
if mhd.Config["visibility"] != "private" {
|
||||
t.Fatalf("mhd visibility = %#v, want private", mhd.Config["visibility"])
|
||||
}
|
||||
if mhd.Config["worksmobileExcluded"] != true {
|
||||
t.Fatalf("mhd worksmobileExcluded = %#v, want true", mhd.Config["worksmobileExcluded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seed-tenant.csv")
|
||||
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||
"Root,COMPANY_GROUP,,protected-root,Root memo,\n"
|
||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||
t.Fatalf("failed to write seed csv: %v", err)
|
||||
}
|
||||
t.Setenv(seedTenantCSVPathEnv, path)
|
||||
|
||||
if !IsSeedTenantSlug("protected-root") {
|
||||
t.Fatal("protected-root must be detected as seed tenant")
|
||||
}
|
||||
if IsSeedTenantSlug("normal-tenant") {
|
||||
t.Fatal("normal-tenant must not be detected as seed tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) {
|
||||
configs := []InitialTenantConfig{
|
||||
{TenantID: "existing-root-id", Name: "Existing Root", Slug: "existing-root"},
|
||||
{Name: "Missing Child", Slug: "missing-child", ParentSlug: "existing-root"},
|
||||
{TenantID: "existing-child-id", Name: "Existing Child", Slug: "existing-child", ParentSlug: "existing-root"},
|
||||
{TenantID: "existing-other-id", Name: "Conflicting ID", Slug: "new-slug"},
|
||||
}
|
||||
existingSlugs := map[string]bool{
|
||||
"existing-root": true,
|
||||
"existing-child": true,
|
||||
}
|
||||
existingIDs := map[string]bool{
|
||||
"existing-root-id": true,
|
||||
"existing-child-id": true,
|
||||
"existing-other-id": true,
|
||||
}
|
||||
|
||||
filtered := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs)
|
||||
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("filtered count = %d, want 1: %#v", len(filtered), filtered)
|
||||
}
|
||||
if filtered[0].Slug != "missing-child" {
|
||||
t.Fatalf("filtered slug = %q, want missing-child", filtered[0].Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
postgresContainer, err := postgres_module.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres_module.WithDatabase("testdb"),
|
||||
postgres_module.WithUsername("user"),
|
||||
postgres_module.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(30*time.Second),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start postgres container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := postgresContainer.Terminate(ctx); err != nil {
|
||||
log.Printf("failed to terminate postgres container: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get postgres connection string: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open postgres connection: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
|
||||
t.Fatalf("failed to migrate seed test tables: %v", err)
|
||||
}
|
||||
|
||||
existingRoot := domain.Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000001",
|
||||
Name: "Existing Root Name",
|
||||
Slug: "existing-root",
|
||||
Type: domain.TenantTypeCompanyGroup,
|
||||
Description: "manual tenant must not be overwritten",
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
nonSeedTenant := domain.Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000002",
|
||||
Name: "Manual Tenant",
|
||||
Slug: "manual-tenant",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := db.Create(&existingRoot).Error; err != nil {
|
||||
t.Fatalf("failed to create existing root tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&nonSeedTenant).Error; err != nil {
|
||||
t.Fatalf("failed to create non-seed tenant: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seed-tenant.csv")
|
||||
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||
"10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" +
|
||||
"00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" +
|
||||
"10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n"
|
||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||
t.Fatalf("failed to write seed csv: %v", err)
|
||||
}
|
||||
t.Setenv(seedTenantCSVPathEnv, path)
|
||||
|
||||
if err := SeedTenants(db); err != nil {
|
||||
t.Fatalf("SeedTenants returned error: %v", err)
|
||||
}
|
||||
|
||||
var root domain.Tenant
|
||||
if err := db.First(&root, "slug = ?", "existing-root").Error; err != nil {
|
||||
t.Fatalf("failed to load existing root after seed: %v", err)
|
||||
}
|
||||
if root.ID != existingRoot.ID {
|
||||
t.Fatalf("existing root ID = %q, want %q", root.ID, existingRoot.ID)
|
||||
}
|
||||
if root.Name != existingRoot.Name {
|
||||
t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name)
|
||||
}
|
||||
|
||||
var child domain.Tenant
|
||||
if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil {
|
||||
t.Fatalf("missing seed child was not created: %v", err)
|
||||
}
|
||||
if child.ParentID == nil || *child.ParentID != existingRoot.ID {
|
||||
t.Fatalf("child parent ID = %v, want %q", child.ParentID, existingRoot.ID)
|
||||
}
|
||||
if len(child.Domains) != 1 || child.Domains[0].Domain != "child.example.com" {
|
||||
t.Fatalf("child domains = %#v, want child.example.com", child.Domains)
|
||||
}
|
||||
|
||||
var rootCount int64
|
||||
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "existing-root").Count(&rootCount).Error; err != nil {
|
||||
t.Fatalf("failed to count existing root rows: %v", err)
|
||||
}
|
||||
if rootCount != 1 {
|
||||
t.Fatalf("existing-root row count = %d, want 1", rootCount)
|
||||
}
|
||||
|
||||
var conflictingIDCount int64
|
||||
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil {
|
||||
t.Fatalf("failed to count conflicting-id rows: %v", err)
|
||||
}
|
||||
if conflictingIDCount != 0 {
|
||||
t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const sanitizeLegacyUserMetadataSQL = `
|
||||
update users
|
||||
set metadata = metadata - 'hanmacFamily' - 'userType',
|
||||
updated_at = now()
|
||||
where metadata ? 'hanmacFamily'
|
||||
or metadata ? 'userType'
|
||||
`
|
||||
|
||||
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
|
||||
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is not configured")
|
||||
}
|
||||
if !db.Migrator().HasTable("users") {
|
||||
slog.Info("[Bootstrap] Legacy user metadata sanitize skipped because users table does not exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
result := db.Exec(sanitizeLegacyUserMetadataSQL)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("sanitize legacy user metadata: %w", result.Error)
|
||||
}
|
||||
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
gorm_postgres "gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSanitizeLegacyUserMetadataRemovesClassificationFlags(t *testing.T) {
|
||||
db := openBootstrapPostgresTestDB(t)
|
||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate users table: %v", err)
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
ID: "10000000-0000-0000-0000-000000000001",
|
||||
Email: "legacy@example.com",
|
||||
Name: "Legacy User",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"hanmacFamily": true,
|
||||
"userType": "hanmac",
|
||||
"employeeId": "E001",
|
||||
"nested": map[string]any{
|
||||
"userType": "must stay nested",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
||||
t.Fatalf("SanitizeLegacyUserMetadata returned error: %v", err)
|
||||
}
|
||||
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
||||
t.Fatalf("SanitizeLegacyUserMetadata must be idempotent: %v", err)
|
||||
}
|
||||
|
||||
var got domain.User
|
||||
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load sanitized user: %v", err)
|
||||
}
|
||||
if _, ok := got.Metadata["hanmacFamily"]; ok {
|
||||
t.Fatalf("hanmacFamily must be removed from metadata: %#v", got.Metadata)
|
||||
}
|
||||
if _, ok := got.Metadata["userType"]; ok {
|
||||
t.Fatalf("userType must be removed from metadata: %#v", got.Metadata)
|
||||
}
|
||||
if got.Metadata["employeeId"] != "E001" {
|
||||
t.Fatalf("employeeId = %#v, want E001", got.Metadata["employeeId"])
|
||||
}
|
||||
nested, ok := got.Metadata["nested"].(map[string]any)
|
||||
if !ok || nested["userType"] != "must stay nested" {
|
||||
t.Fatalf("nested metadata must be preserved: %#v", got.Metadata["nested"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
|
||||
db := openBootstrapPostgresTestDB(t)
|
||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate users table: %v", err)
|
||||
}
|
||||
|
||||
users := []domain.User{
|
||||
{ID: "11000000-0000-0000-0000-000000000001", Email: "inactive@example.com", Name: "Inactive", Role: domain.RoleUser, Status: "inactive"},
|
||||
{ID: "11000000-0000-0000-0000-000000000002", Email: "leave@example.com", Name: "Leave", Role: domain.RoleUser, Status: "leave_of_absence"},
|
||||
{ID: "11000000-0000-0000-0000-000000000003", Email: "baron-only@example.com", Name: "Baron Only", Role: domain.RoleUser, Status: "baron_only"},
|
||||
{ID: "11000000-0000-0000-0000-000000000004", Email: "active@example.com", Name: "Active", Role: domain.RoleUser, Status: domain.UserStatusActive},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatalf("failed to create users: %v", err)
|
||||
}
|
||||
|
||||
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
|
||||
t.Fatalf("CanonicalizeLegacyUserStatuses returned error: %v", err)
|
||||
}
|
||||
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
|
||||
t.Fatalf("CanonicalizeLegacyUserStatuses must be idempotent: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
var loaded []domain.User
|
||||
if err := db.Find(&loaded).Error; err != nil {
|
||||
t.Fatalf("failed to load users: %v", err)
|
||||
}
|
||||
for _, user := range loaded {
|
||||
got[user.Email] = user.Status
|
||||
}
|
||||
|
||||
if got["inactive@example.com"] != domain.UserStatusPreboarding {
|
||||
t.Fatalf("inactive status = %q, want %q", got["inactive@example.com"], domain.UserStatusPreboarding)
|
||||
}
|
||||
if got["leave@example.com"] != domain.UserStatusTemporaryLeave {
|
||||
t.Fatalf("leave status = %q, want %q", got["leave@example.com"], domain.UserStatusTemporaryLeave)
|
||||
}
|
||||
if got["baron-only@example.com"] != domain.UserStatusBaronGuest {
|
||||
t.Fatalf("baron_only status = %q, want %q", got["baron-only@example.com"], domain.UserStatusBaronGuest)
|
||||
}
|
||||
if got["active@example.com"] != domain.UserStatusActive {
|
||||
t.Fatalf("active status = %q, want %q", got["active@example.com"], domain.UserStatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
|
||||
db := openBootstrapPostgresTestDB(t)
|
||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate users table: %v", err)
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
ID: "20000000-0000-0000-0000-000000000001",
|
||||
Email: "run-legacy@example.com",
|
||||
Name: "Run Legacy User",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"hanmacFamily": true,
|
||||
"userType": "external",
|
||||
"employeeId": "E002",
|
||||
},
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seed-tenant.csv")
|
||||
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||
"30000000-0000-0000-0000-000000000001,Seed Root,COMPANY_GROUP,,seed-root,seed root,\n"
|
||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||
t.Fatalf("failed to write seed csv: %v", err)
|
||||
}
|
||||
t.Setenv(seedTenantCSVPathEnv, path)
|
||||
|
||||
if err := Run(db); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
var got domain.User
|
||||
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load sanitized user: %v", err)
|
||||
}
|
||||
if _, ok := got.Metadata["hanmacFamily"]; ok {
|
||||
t.Fatalf("Run must remove hanmacFamily from metadata: %#v", got.Metadata)
|
||||
}
|
||||
if _, ok := got.Metadata["userType"]; ok {
|
||||
t.Fatalf("Run must remove userType from metadata: %#v", got.Metadata)
|
||||
}
|
||||
if got.Metadata["employeeId"] != "E002" {
|
||||
t.Fatalf("employeeId = %#v, want E002", got.Metadata["employeeId"])
|
||||
}
|
||||
}
|
||||
|
||||
func openBootstrapPostgresTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
postgresContainer, err := postgres_module.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres_module.WithDatabase("testdb"),
|
||||
postgres_module.WithUsername("user"),
|
||||
postgres_module.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(30*time.Second),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start postgres container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := postgresContainer.Terminate(ctx); err != nil {
|
||||
log.Printf("failed to terminate postgres container: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get postgres connection string: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open postgres connection: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiKey represents an internal API key for Machine-to-Machine communication.
|
||||
type ApiKey struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
ClientID string `gorm:"uniqueIndex;not null" json:"clientId"`
|
||||
ClientSecretHash string `gorm:"not null" json:"-"`
|
||||
Scopes string `json:"scopes"` // Space or comma separated
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (k *ApiKey) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if k.ID == "" {
|
||||
k.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package domain
|
||||
|
||||
type EnchantedLinkInitRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||
CodeOnly bool `json:"codeOnly,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
DrySend bool `json:"drySend,omitempty"`
|
||||
}
|
||||
|
||||
type EnchantedLinkInitResponse struct {
|
||||
LinkID string `json:"linkId"`
|
||||
PendingRef string `json:"pendingRef"`
|
||||
MaskedEmail string `json:"maskedEmail"`
|
||||
}
|
||||
|
||||
type EnchantedLinkPollRequest struct {
|
||||
PendingRef string `json:"pendingRef"`
|
||||
}
|
||||
|
||||
type EnchantedLinkPollResponse struct {
|
||||
SessionToken string `json:"sessionToken"` // JWT
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
}
|
||||
|
||||
type MagicLinkVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
||||
}
|
||||
|
||||
type QRInitResponse struct {
|
||||
QRCode string `json:"qrCode"` // Base64 or URL
|
||||
PendingRef string `json:"pendingRef"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
}
|
||||
|
||||
// Signup Flow Models
|
||||
|
||||
type CheckEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type SendSignupCodeRequest struct {
|
||||
Target string `json:"target"` // Email or Phone
|
||||
Type string `json:"type"` // "email" or "phone"
|
||||
}
|
||||
|
||||
type VerifySignupCodeRequest struct {
|
||||
Target string `json:"target"` // Email or Phone
|
||||
Type string `json:"type"` // "email" or "phone"
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type SignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId,omitempty"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
Department string `json:"department"`
|
||||
Metadata JSONMap `json:"metadata,omitempty"`
|
||||
TermsAccepted bool `json:"termsAccepted"`
|
||||
}
|
||||
|
||||
// User Profile Models
|
||||
|
||||
type UserProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Department string `json:"department"`
|
||||
VerificationCode string `json:"verificationCode,omitempty"` // For phone change
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
||||
type PasswordResetInitiateRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
DrySend bool `json:"drySend,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest is the request body for completing a password reset.
|
||||
type PasswordResetCompleteRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// PasswordChangeRequest는 로그인 상태에서 비밀번호 변경 요청을 표현합니다.
|
||||
type PasswordChangeRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type CheckLoginIDRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClientConsent struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
ClientID string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"clientId"`
|
||||
Subject string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"subject"` // User UUID
|
||||
GrantedScopes pq.StringArray `gorm:"type:text[];not null" json:"grantedScopes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// ClientConsentWithTenantInfo is a struct to hold joined data for API responses
|
||||
type ClientConsentWithTenantInfo struct {
|
||||
ClientConsent
|
||||
TenantID string `gorm:"column:tenant_id" json:"tenantId"`
|
||||
TenantName string `gorm:"column:tenant_name" json:"tenantName"`
|
||||
}
|
||||
|
||||
func (c *ClientConsent) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClientSecret represents the stored client secret for OIDC clients.
|
||||
// Since Hydra only returns the secret once during creation, we store it here.
|
||||
type ClientSecret struct {
|
||||
ClientID string `gorm:"primaryKey;column:client_id"`
|
||||
ClientSecret string `gorm:"column:client_secret;not null"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientSecretRepository interface {
|
||||
Upsert(ctx context.Context, clientID, secret string) error
|
||||
GetByID(ctx context.Context, clientID string) (string, error)
|
||||
Delete(ctx context.Context, clientID string) error
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type DataIntegrityStatus string
|
||||
|
||||
const (
|
||||
DataIntegrityStatusPass DataIntegrityStatus = "pass"
|
||||
DataIntegrityStatusWarning DataIntegrityStatus = "warning"
|
||||
DataIntegrityStatusFail DataIntegrityStatus = "fail"
|
||||
)
|
||||
|
||||
type DataIntegrityReport struct {
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
Summary DataIntegritySummary `json:"summary"`
|
||||
Sections []DataIntegritySection `json:"sections"`
|
||||
}
|
||||
|
||||
type DataIntegritySummary struct {
|
||||
TotalChecks int `json:"totalChecks"`
|
||||
Passed int `json:"passed"`
|
||||
Warnings int `json:"warnings"`
|
||||
Failures int64 `json:"failures"`
|
||||
}
|
||||
|
||||
type DataIntegritySection struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
Checks []DataIntegrityCheck `json:"checks"`
|
||||
}
|
||||
|
||||
type DataIntegrityCheck struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type OrphanUserLoginID struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
UserDeletedAt *time.Time `json:"userDeletedAt,omitempty"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
TenantDeletedAt *time.Time `json:"tenantDeletedAt,omitempty"`
|
||||
FieldKey string `json:"fieldKey"`
|
||||
LoginID string `json:"loginId"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
type DeleteOrphanUserLoginIDsResult struct {
|
||||
DeletedCount int64 `json:"deletedCount"`
|
||||
Deleted []OrphanUserLoginID `json:"deleted"`
|
||||
SkippedIDs []string `json:"skippedIds"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DeveloperRequestStatusPending = "pending"
|
||||
DeveloperRequestStatusApproved = "approved"
|
||||
DeveloperRequestStatusRejected = "rejected"
|
||||
DeveloperRequestStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// DeveloperRequest represents a user's application to become a developer.
|
||||
type DeveloperRequest struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
|
||||
TenantID string `gorm:"index;not null" json:"tenantId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package domain
|
||||
|
||||
// EmailService defines the interface for sending emails.
|
||||
type EmailService interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ProviderType defines the type of the identity provider.
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderTypeOIDC ProviderType = "oidc"
|
||||
ProviderTypeSAML ProviderType = "saml"
|
||||
)
|
||||
|
||||
// IdentityProviderConfig stores the configuration for an external Identity Provider.
|
||||
type IdentityProviderConfig struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID
|
||||
ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
|
||||
DisplayName string `gorm:"not null" json:"display_name"`
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
|
||||
// OIDC Specific Fields
|
||||
IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
|
||||
OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
|
||||
OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
|
||||
// Scopes are space-separated
|
||||
Scopes *string `gorm:"null" json:"scopes,omitempty"`
|
||||
|
||||
// SAML Specific Fields
|
||||
MetadataURL *string `gorm:"null" json:"metadata_url,omitempty"`
|
||||
MetadataXML *string `gorm:"type:text;null" json:"metadata_xml,omitempty"`
|
||||
EntityID *string `gorm:"null" json:"entity_id,omitempty"`
|
||||
AcsURL *string `gorm:"null" json:"acs_url,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (idc *IdentityProviderConfig) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if idc.ID == "" {
|
||||
idc.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var hanmacSurnameRomanization = map[rune]string{
|
||||
'한': "han",
|
||||
'김': "kim",
|
||||
'이': "lee",
|
||||
'박': "park",
|
||||
'최': "choi",
|
||||
'정': "jung",
|
||||
'조': "cho",
|
||||
'강': "kang",
|
||||
'윤': "yoon",
|
||||
'장': "jang",
|
||||
'임': "lim",
|
||||
'림': "lim",
|
||||
'신': "shin",
|
||||
'오': "oh",
|
||||
'서': "seo",
|
||||
'권': "kwon",
|
||||
'황': "hwang",
|
||||
'안': "ahn",
|
||||
'송': "song",
|
||||
'전': "jeon",
|
||||
'홍': "hong",
|
||||
'유': "yoo",
|
||||
'고': "ko",
|
||||
'문': "moon",
|
||||
'양': "yang",
|
||||
'손': "son",
|
||||
'배': "bae",
|
||||
'백': "baek",
|
||||
'허': "heo",
|
||||
'남': "nam",
|
||||
'심': "sim",
|
||||
'노': "noh",
|
||||
'하': "ha",
|
||||
'곽': "kwak",
|
||||
'성': "sung",
|
||||
'차': "cha",
|
||||
'주': "joo",
|
||||
'우': "woo",
|
||||
'구': "koo",
|
||||
'민': "min",
|
||||
'류': "ryu",
|
||||
'나': "na",
|
||||
'진': "jin",
|
||||
'지': "ji",
|
||||
'엄': "um",
|
||||
'채': "chae",
|
||||
'원': "won",
|
||||
'천': "cheon",
|
||||
'방': "bang",
|
||||
'공': "gong",
|
||||
'현': "hyun",
|
||||
'함': "ham",
|
||||
'여': "yeo",
|
||||
'추': "choo",
|
||||
'도': "do",
|
||||
'소': "so",
|
||||
'석': "seok",
|
||||
'선': "sun",
|
||||
'설': "seol",
|
||||
'마': "ma",
|
||||
'길': "gil",
|
||||
'연': "yeon",
|
||||
'위': "wi",
|
||||
'표': "pyo",
|
||||
'명': "myung",
|
||||
'기': "ki",
|
||||
'반': "ban",
|
||||
'라': "ra",
|
||||
'왕': "wang",
|
||||
'금': "geum",
|
||||
'옥': "ok",
|
||||
'육': "yook",
|
||||
'인': "in",
|
||||
'맹': "maeng",
|
||||
'제': "je",
|
||||
'모': "mo",
|
||||
'탁': "tak",
|
||||
'국': "guk",
|
||||
'어': "eo",
|
||||
'은': "eun",
|
||||
'편': "pyeon",
|
||||
'용': "yong",
|
||||
}
|
||||
|
||||
var hanmacInitialRomanization = []string{
|
||||
"g", "g", "n", "d", "d", "r", "m", "b", "b", "s",
|
||||
"s", "y", "j", "j", "c", "k", "t", "p", "h",
|
||||
}
|
||||
|
||||
func SplitEmailDomain(email string) (string, string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
before, after, ok := strings.Cut(normalized, "@")
|
||||
if !ok {
|
||||
return "", "", errors.New("email must contain @")
|
||||
}
|
||||
if strings.Count(normalized, "@") != 1 {
|
||||
return "", "", errors.New("email must contain one @")
|
||||
}
|
||||
localPart := strings.TrimSpace(before)
|
||||
domainPart := strings.TrimSpace(after)
|
||||
if domainPart == "" || !strings.Contains(domainPart, ".") {
|
||||
return "", "", errors.New("email domain is invalid")
|
||||
}
|
||||
return localPart, domainPart, nil
|
||||
}
|
||||
|
||||
func ExtractNormalizedEmailLocalPart(email string) (string, error) {
|
||||
localPart, _, err := SplitEmailDomain(email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if localPart == "" {
|
||||
return "", errors.New("email local-part is empty")
|
||||
}
|
||||
return localPart, nil
|
||||
}
|
||||
|
||||
func BuildKoreanNameEmailBase(name string) (string, bool, error) {
|
||||
runes := compactNameRunes(name)
|
||||
if len(runes) < 2 {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
surname, ok := hanmacSurnameRomanization[runes[0]]
|
||||
if !ok {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, r := range runes[1:] {
|
||||
initial, ok := romanizedHangulInitial(r)
|
||||
if !ok {
|
||||
return "", true, nil
|
||||
}
|
||||
builder.WriteString(initial)
|
||||
}
|
||||
builder.WriteString(surname)
|
||||
return builder.String(), false, nil
|
||||
}
|
||||
|
||||
func MatchesSuggestedNameRule(localPart string, base string) bool {
|
||||
localPart = strings.ToLower(strings.TrimSpace(localPart))
|
||||
base = strings.ToLower(strings.TrimSpace(base))
|
||||
if localPart == "" || base == "" {
|
||||
return false
|
||||
}
|
||||
if localPart == base {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(localPart, base) {
|
||||
return false
|
||||
}
|
||||
suffix := localPart[len(base):]
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func compactNameRunes(name string) []rune {
|
||||
var runes []rune
|
||||
for _, r := range strings.TrimSpace(name) {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
runes = append(runes, r)
|
||||
}
|
||||
return runes
|
||||
}
|
||||
|
||||
func romanizedHangulInitial(r rune) (string, bool) {
|
||||
const hangulBase = 0xAC00
|
||||
const hangulEnd = 0xD7A3
|
||||
if r < hangulBase || r > hangulEnd {
|
||||
return "", false
|
||||
}
|
||||
index := int(r-hangulBase) / 588
|
||||
if index < 0 || index >= len(hanmacInitialRomanization) {
|
||||
return "", false
|
||||
}
|
||||
return hanmacInitialRomanization[index], true
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitEmailDomainAllowsDomainOnlyImportInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
wantLocal string
|
||||
wantDomain string
|
||||
}{
|
||||
{name: "full address", email: " Han@SamanEng.com ", wantLocal: "han", wantDomain: "samaneng.com"},
|
||||
{name: "domain only", email: "@hanmaceng.co.kr", wantLocal: "", wantDomain: "hanmaceng.co.kr"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
local, domain, err := SplitEmailDomain(tt.email)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitEmailDomain() error = %v", err)
|
||||
}
|
||||
if local != tt.wantLocal || domain != tt.wantDomain {
|
||||
t.Fatalf("SplitEmailDomain() = (%q, %q), want (%q, %q)", local, domain, tt.wantLocal, tt.wantDomain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKoreanNameEmailBase(t *testing.T) {
|
||||
base, needsReview, err := BuildKoreanNameEmailBase("한치영")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
|
||||
}
|
||||
if needsReview {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() needsReview = true")
|
||||
}
|
||||
if base != "cyhan" {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() = %q, want %q", base, "cyhan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKoreanNameEmailBaseNeedsReviewForUnknownName(t *testing.T) {
|
||||
base, needsReview, err := BuildKoreanNameEmailBase("A치영")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
|
||||
}
|
||||
if base != "" {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() base = %q, want empty", base)
|
||||
}
|
||||
if !needsReview {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() needsReview = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesSuggestedNameRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
localPart string
|
||||
base string
|
||||
want bool
|
||||
}{
|
||||
{localPart: "cyhan", base: "cyhan", want: true},
|
||||
{localPart: "cyhan1", base: "cyhan", want: true},
|
||||
{localPart: "cyhan20", base: "cyhan", want: true},
|
||||
{localPart: "hcy", base: "cyhan", want: false},
|
||||
{localPart: "han.cy", base: "cyhan", want: false},
|
||||
{localPart: "cyhan-a", base: "cyhan", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.localPart, func(t *testing.T) {
|
||||
if got := MatchesSuggestedNameRule(tt.localPart, tt.base); got != tt.want {
|
||||
t.Fatalf("MatchesSuggestedNameRule(%q, %q) = %v, want %v", tt.localPart, tt.base, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type HeadlessJWKSParsedKey struct {
|
||||
Kid string `json:"kid,omitempty"`
|
||||
Kty string `json:"kty,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
N string `json:"n,omitempty"`
|
||||
}
|
||||
|
||||
// HeadlessJWKSCacheState는 headless login용 JWKS 캐시 상태와 최근 동기화 결과를 나타냅니다.
|
||||
type HeadlessJWKSCacheState struct {
|
||||
ClientID string `json:"clientId"`
|
||||
JWKSURI string `json:"jwksUri"`
|
||||
CachedAt *time.Time `json:"cachedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
|
||||
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
|
||||
LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"`
|
||||
LastRefreshStatus string `json:"lastRefreshStatus,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
|
||||
CachedKids []string `json:"cachedKids,omitempty"`
|
||||
ParsedKeys []HeadlessJWKSParsedKey `json:"parsedKeys,omitempty"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
RawJWKS string `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||
MetadataHeadlessJWKS = "headless_jwks"
|
||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||
MetadataIDTokenClaims = "id_token_claims"
|
||||
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
|
||||
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
|
||||
MetadataAutoLoginSupported = "auto_login_supported"
|
||||
MetadataAutoLoginURL = "auto_login_url"
|
||||
)
|
||||
|
||||
type HydraClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||
JWKS any `json:"jwks,omitempty"`
|
||||
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
|
||||
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||
// Headless login now supports jwksUri only.
|
||||
hasPublicKey := c.HeadlessJWKSURI() != ""
|
||||
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
|
||||
return hasPublicKey && isPrivateKeyJwt
|
||||
}
|
||||
|
||||
func (c *HydraClient) HeadlessTokenEndpointAuthMethod() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataHeadlessTokenEndpointAuthMethod].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.TokenEndpointAuthMethod)
|
||||
}
|
||||
|
||||
func (c *HydraClient) HeadlessJWKSURI() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataHeadlessJWKSURI].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.JWKSUri)
|
||||
}
|
||||
|
||||
func (c *HydraClient) HeadlessJWKS() any {
|
||||
if c.Metadata != nil {
|
||||
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return c.JWKS
|
||||
}
|
||||
|
||||
func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
||||
if !c.SupportsHeadlessLogin() {
|
||||
return false
|
||||
}
|
||||
if c.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
val, ok := c.Metadata[MetadataHeadlessLoginEnabled]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if b, ok := val.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *HydraClient) BackchannelLogoutURI() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataBackChannelLogoutURI].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.BackChannelLogoutURI)
|
||||
}
|
||||
|
||||
func (c *HydraClient) BackchannelLogoutSessionRequiredValue() bool {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataBackChannelLogoutSessionRequired].(bool); ok {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
if c.BackChannelLogoutSessionRequired != nil {
|
||||
return *c.BackChannelLogoutSessionRequired
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type HydraConsentRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RequestedScope []string `json:"requested_scope"`
|
||||
RequestedAudience []string `json:"requested_access_token_audience"`
|
||||
Skip bool `json:"skip"`
|
||||
Subject string `json:"subject"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraLoginRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Subject string `json:"subject"`
|
||||
Skip bool `json:"skip"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraConsentSession struct {
|
||||
ConsentRequestID string `json:"consent_request_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
GrantedScope []string `json:"grant_scope,omitempty"`
|
||||
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
|
||||
Remember bool `json:"remember"`
|
||||
RememberFor int `json:"remember_for,omitempty"`
|
||||
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
||||
RequestedAt *time.Time `json:"requested_at,omitempty"`
|
||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||
Client HydraClient `json:"client"`
|
||||
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||
t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
}
|
||||
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected metadata-backed headless login client")
|
||||
}
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected metadata-backed headless login enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline jwks without jwks uri does not support headless login", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kty": "RSA",
|
||||
}},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}
|
||||
|
||||
if client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login prerequisites to be missing")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled without jwks uri")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "none",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}
|
||||
|
||||
if client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login prerequisites to be missing")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled when prerequisites are missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": "true",
|
||||
},
|
||||
}
|
||||
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login client")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled for non-bool metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHydraClientHeadlessMetadataAccessors(t *testing.T) {
|
||||
t.Run("metadata values override inline values", func(t *testing.T) {
|
||||
metadataJWKS := map[string]any{"keys": []any{"metadata-key"}}
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "client_secret_post",
|
||||
JWKSUri: "https://inline.example.com/jwks.json",
|
||||
JWKS: map[string]any{"keys": []any{"inline-key"}},
|
||||
Metadata: map[string]any{
|
||||
MetadataHeadlessTokenEndpointAuthMethod: " private_key_jwt ",
|
||||
MetadataHeadlessJWKSURI: " https://metadata.example.com/jwks.json ",
|
||||
MetadataHeadlessJWKS: metadataJWKS,
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.HeadlessTokenEndpointAuthMethod(); got != "private_key_jwt" {
|
||||
t.Fatalf("unexpected auth method: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKSURI(); got != "https://metadata.example.com/jwks.json" {
|
||||
t.Fatalf("unexpected jwks uri: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKS(); !reflect.DeepEqual(got, metadataJWKS) {
|
||||
t.Fatalf("unexpected jwks value: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank or missing metadata values fall back to inline values", func(t *testing.T) {
|
||||
inlineJWKS := map[string]any{"keys": []any{"inline-key"}}
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: " private_key_jwt ",
|
||||
JWKSUri: " https://inline.example.com/jwks.json ",
|
||||
JWKS: inlineJWKS,
|
||||
Metadata: map[string]any{
|
||||
MetadataHeadlessTokenEndpointAuthMethod: " ",
|
||||
MetadataHeadlessJWKSURI: " ",
|
||||
MetadataHeadlessJWKS: nil,
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.HeadlessTokenEndpointAuthMethod(); got != "private_key_jwt" {
|
||||
t.Fatalf("unexpected auth method: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKSURI(); got != "https://inline.example.com/jwks.json" {
|
||||
t.Fatalf("unexpected jwks uri: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKS(); !reflect.DeepEqual(got, inlineJWKS) {
|
||||
t.Fatalf("unexpected jwks value: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHydraClientBackchannelLogoutAccessors(t *testing.T) {
|
||||
t.Run("metadata values override inline values", func(t *testing.T) {
|
||||
inlineRequired := false
|
||||
client := HydraClient{
|
||||
BackChannelLogoutURI: "https://inline.example.com/logout",
|
||||
BackChannelLogoutSessionRequired: &inlineRequired,
|
||||
Metadata: map[string]any{
|
||||
MetadataBackChannelLogoutURI: " https://metadata.example.com/logout ",
|
||||
MetadataBackChannelLogoutSessionRequired: true,
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.BackchannelLogoutURI(); got != "https://metadata.example.com/logout" {
|
||||
t.Fatalf("unexpected logout uri: %q", got)
|
||||
}
|
||||
if !client.BackchannelLogoutSessionRequiredValue() {
|
||||
t.Fatalf("expected metadata session_required value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank or missing metadata values fall back to inline values", func(t *testing.T) {
|
||||
inlineRequired := true
|
||||
client := HydraClient{
|
||||
BackChannelLogoutURI: " https://inline.example.com/logout ",
|
||||
BackChannelLogoutSessionRequired: &inlineRequired,
|
||||
Metadata: map[string]any{
|
||||
MetadataBackChannelLogoutURI: " ",
|
||||
MetadataBackChannelLogoutSessionRequired: "true",
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.BackchannelLogoutURI(); got != "https://inline.example.com/logout" {
|
||||
t.Fatalf("unexpected logout uri: %q", got)
|
||||
}
|
||||
if !client.BackchannelLogoutSessionRequiredValue() {
|
||||
t.Fatalf("expected inline session_required value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing session required defaults to false", func(t *testing.T) {
|
||||
client := HydraClient{}
|
||||
|
||||
if got := client.BackchannelLogoutURI(); got != "" {
|
||||
t.Fatalf("unexpected logout uri: %q", got)
|
||||
}
|
||||
if client.BackchannelLogoutSessionRequiredValue() {
|
||||
t.Fatalf("expected default session_required false")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type IdentityCacheStatus struct {
|
||||
Status string `json:"status"`
|
||||
RedisReady bool `json:"redisReady"`
|
||||
ObservedCount int64 `json:"observedCount"`
|
||||
KeyCount int64 `json:"keyCount"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type IdentityCacheFlushResult struct {
|
||||
Status string `json:"status"`
|
||||
FlushedKeys int64 `json:"flushedKeys"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다.
|
||||
var ErrNotSupported = errors.New("idp: not supported")
|
||||
|
||||
// BrokerUser is the standard user model used within Baron SSO business logic.
|
||||
// It defines the canonical set of fields that must be supported by any underlying IDP.
|
||||
type BrokerUser struct {
|
||||
ID string `json:"id" required:"true"`
|
||||
Email string `json:"email" required:"true"`
|
||||
LoginID string `json:"login_id"`
|
||||
CustomLoginIDs []string `json:"custom_login_ids"` // [New] 다중 로그인 ID
|
||||
Name string `json:"name"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
// Attributes stores custom user attributes.
|
||||
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
|
||||
Attributes map[string]any `json:"attributes" required_keys:"grade,department"`
|
||||
}
|
||||
|
||||
// IDPMetadata represents the schema capabilities of an Identity Provider.
|
||||
type IDPMetadata struct {
|
||||
// SupportedFields lists the BrokerUser fields (json tag names) that the IDP supports.
|
||||
// For custom attributes, use the key name directly (e.g., "grade").
|
||||
SupportedFields []string
|
||||
}
|
||||
|
||||
// PasswordPolicy는 비밀번호 정책 정보를 표현합니다.
|
||||
type PasswordPolicy struct {
|
||||
MinLength int
|
||||
Lowercase bool
|
||||
Uppercase bool
|
||||
Number bool
|
||||
NonAlphanumeric bool
|
||||
MinCharacterTypes int
|
||||
}
|
||||
|
||||
// Token represents a session or refresh token.
|
||||
type Token struct {
|
||||
JWT string
|
||||
Expiration time.Time
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// AuthInfo contains authentication information after a successful login.
|
||||
type AuthInfo struct {
|
||||
SessionToken *Token
|
||||
RefreshToken *Token
|
||||
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
|
||||
Subject string
|
||||
SetCookies []*http.Cookie
|
||||
}
|
||||
|
||||
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
|
||||
type LinkLoginInit struct {
|
||||
FlowID string
|
||||
ExpiresAt time.Time
|
||||
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
|
||||
Mode string
|
||||
// LoginID는 IDP에 실제 전달된 식별자입니다.
|
||||
LoginID string
|
||||
}
|
||||
|
||||
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||
type IdentityProvider interface {
|
||||
Name() string
|
||||
// GetMetadata returns the schema support information for this IDP.
|
||||
// This is used for startup-time validation.
|
||||
GetMetadata() (*IDPMetadata, error)
|
||||
// CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다.
|
||||
CreateUser(user *BrokerUser, password string) (string, error)
|
||||
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
|
||||
SignIn(loginID, password string) (*AuthInfo, error)
|
||||
// UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다.
|
||||
UserExists(loginID string) (bool, error)
|
||||
// IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다.
|
||||
IssueSession(loginID string) (*AuthInfo, error)
|
||||
// InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다.
|
||||
InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error)
|
||||
// VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다.
|
||||
VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error)
|
||||
// GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다.
|
||||
GetPasswordPolicy() (*PasswordPolicy, error)
|
||||
InitiatePasswordReset(loginID, redirectUrl string) error
|
||||
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
||||
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// JSONMap is a custom type for handling map[string]any with PostgreSQL JSONB
|
||||
type JSONMap map[string]any
|
||||
|
||||
// Value implements the driver.Valuer interface
|
||||
func (m JSONMap) Value() (driver.Value, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ba, err := json.Marshal(m)
|
||||
return string(ba), err
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface
|
||||
func (m *JSONMap) Scan(value any) error {
|
||||
if value == nil {
|
||||
*m = make(JSONMap)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New(fmt.Sprintf("failed to scan JSONMap: %v", value))
|
||||
}
|
||||
|
||||
result := make(JSONMap)
|
||||
err := json.Unmarshal(bytes, &result)
|
||||
*m = result
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONMapValue(t *testing.T) {
|
||||
t.Run("nil map returns nil database value", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
value, err := payload.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if value != nil {
|
||||
t.Fatalf("expected nil value, got %v", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map marshals to JSON string", func(t *testing.T) {
|
||||
payload := JSONMap{"enabled": true, "name": "baron"}
|
||||
|
||||
value, err := payload.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected string value, got %T", value)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatalf("value should be valid json: %v", err)
|
||||
}
|
||||
if decoded["enabled"] != true || decoded["name"] != "baron" {
|
||||
t.Fatalf("unexpected decoded value: %#v", decoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJSONMapScan(t *testing.T) {
|
||||
t.Run("nil value becomes empty map", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload == nil || len(payload) != 0 {
|
||||
t.Fatalf("expected empty map, got %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("byte slice value decodes JSON", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan([]byte(`{"count":2,"name":"baron"}`)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["name"] != "baron" {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string value decodes JSON", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(`{"active":true}`); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload["active"] != true {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported value type returns error", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(42); err == nil {
|
||||
t.Fatalf("expected unsupported type error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(`{invalid`); err == nil {
|
||||
t.Fatalf("expected invalid JSON error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KetoOutbox status
|
||||
const (
|
||||
KetoOutboxStatusPending = "pending"
|
||||
KetoOutboxStatusProcessed = "processed"
|
||||
KetoOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// KetoOutbox action
|
||||
const (
|
||||
KetoOutboxActionCreate = "CREATE"
|
||||
KetoOutboxActionDelete = "DELETE"
|
||||
)
|
||||
|
||||
// KetoOutbox represents a Keto relationship tuple update event.
|
||||
type KetoOutbox struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Namespace string `gorm:"not null" json:"namespace"`
|
||||
Object string `gorm:"not null" json:"object"`
|
||||
Relation string `gorm:"not null" json:"relation"`
|
||||
Subject string `gorm:"not null" json:"subject"` // format: "User:ID" or "Tenant:ID#members"
|
||||
Action string `gorm:"not null" json:"action"` // CREATE, DELETE
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (ko *KetoOutbox) TableName() string {
|
||||
return "keto_outbox"
|
||||
}
|
||||
|
||||
func (ko *KetoOutbox) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if ko.ID == "" {
|
||||
ko.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func requireGeneratedUUID(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
|
||||
if value == "" {
|
||||
t.Fatalf("expected generated uuid")
|
||||
}
|
||||
if _, err := uuid.Parse(value); err != nil {
|
||||
t.Fatalf("expected valid uuid, got %q: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCreateGeneratesMissingIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "api key",
|
||||
run: func(t *testing.T) {
|
||||
model := ApiKey{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client consent",
|
||||
run: func(t *testing.T) {
|
||||
model := ClientConsent{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity provider config",
|
||||
run: func(t *testing.T) {
|
||||
model := IdentityProviderConfig{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keto outbox",
|
||||
run: func(t *testing.T) {
|
||||
model := KetoOutbox{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
run: func(t *testing.T) {
|
||||
model := Tenant{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant domain",
|
||||
run: func(t *testing.T) {
|
||||
model := TenantDomain{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
run: func(t *testing.T) {
|
||||
model := User{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user group",
|
||||
run: func(t *testing.T) {
|
||||
model := UserGroup{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worksmobile resource mapping",
|
||||
run: func(t *testing.T) {
|
||||
model := WorksmobileResourceMapping{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, tc.run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCreatePreservesExistingIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "api key",
|
||||
run: func(t *testing.T) {
|
||||
model := ApiKey{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client consent",
|
||||
run: func(t *testing.T) {
|
||||
model := ClientConsent{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity provider config",
|
||||
run: func(t *testing.T) {
|
||||
model := IdentityProviderConfig{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keto outbox",
|
||||
run: func(t *testing.T) {
|
||||
model := KetoOutbox{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
run: func(t *testing.T) {
|
||||
model := Tenant{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant domain",
|
||||
run: func(t *testing.T) {
|
||||
model := TenantDomain{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
run: func(t *testing.T) {
|
||||
model := User{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user group",
|
||||
run: func(t *testing.T) {
|
||||
model := UserGroup{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worksmobile resource mapping",
|
||||
run: func(t *testing.T) {
|
||||
model := WorksmobileResourceMapping{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, tc.run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
expected string
|
||||
}{
|
||||
{name: "keto outbox", got: (&KetoOutbox{}).TableName(), expected: "keto_outbox"},
|
||||
{name: "rp usage event", got: (&RPUsageEvent{}).TableName(), expected: "rp_usage_outbox"},
|
||||
{name: "rp user metadata", got: (RPUserMetadata{}).TableName(), expected: "rp_user_metadata"},
|
||||
{name: "user group", got: (&UserGroup{}).TableName(), expected: "user_groups"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.got != tc.expected {
|
||||
t.Fatalf("unexpected table name: got=%s expected=%s", tc.got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantIsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
expected bool
|
||||
}{
|
||||
{name: "active", status: TenantStatusActive, expected: true},
|
||||
{name: "pending", status: TenantStatusPending, expected: false},
|
||||
{name: "suspended", status: TenantStatusSuspended, expected: false},
|
||||
{name: "deleted", status: TenantStatusDeleted, expected: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tenant := Tenant{Status: tc.status}
|
||||
if got := tenant.IsActive(); got != tc.expected {
|
||||
t.Fatalf("unexpected active state: got=%v expected=%v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPUsageEventBeforeCreateDefaults(t *testing.T) {
|
||||
event := RPUsageEvent{}
|
||||
|
||||
if err := event.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, event.ID)
|
||||
if event.Status != RPUsageOutboxStatusPending {
|
||||
t.Fatalf("unexpected status: %s", event.Status)
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
t.Fatalf("expected occurred_at default")
|
||||
}
|
||||
if event.Payload == nil {
|
||||
t.Fatalf("expected empty payload default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPUsageEventBeforeCreatePreservesExplicitValues(t *testing.T) {
|
||||
occurredAt := time.Date(2026, 5, 29, 1, 2, 3, 0, time.UTC)
|
||||
event := RPUsageEvent{
|
||||
ID: "existing-id",
|
||||
Status: RPUsageOutboxStatusProcessing,
|
||||
OccurredAt: occurredAt,
|
||||
Payload: JSONMap{"source": "test"},
|
||||
}
|
||||
|
||||
if err := event.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if event.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
if event.Status != RPUsageOutboxStatusProcessing {
|
||||
t.Fatalf("expected status to be preserved")
|
||||
}
|
||||
if !event.OccurredAt.Equal(occurredAt) {
|
||||
t.Fatalf("expected occurred_at to be preserved")
|
||||
}
|
||||
if event.Payload["source"] != "test" {
|
||||
t.Fatalf("expected payload to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxBeforeCreateDefaults(t *testing.T) {
|
||||
outbox := WorksmobileOutbox{}
|
||||
|
||||
if err := outbox.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, outbox.ID)
|
||||
if outbox.Status != WorksmobileOutboxStatusPending {
|
||||
t.Fatalf("unexpected status: %s", outbox.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxBeforeCreatePreservesExplicitValues(t *testing.T) {
|
||||
outbox := WorksmobileOutbox{
|
||||
ID: "existing-id",
|
||||
Status: WorksmobileOutboxStatusProcessing,
|
||||
}
|
||||
|
||||
if err := outbox.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if outbox.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
if outbox.Status != WorksmobileOutboxStatusProcessing {
|
||||
t.Fatalf("expected status to be preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditLog represents a single audit event
|
||||
type AuditLog struct {
|
||||
EventID string `json:"event_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
UserID string `json:"user_id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
||||
Status string `json:"status"` // e.g., "success", "failure"
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Details string `json:"details,omitempty"` // JSON string or simple text
|
||||
}
|
||||
|
||||
// AuditRepository defines interface for storing logs
|
||||
type AuditRepository interface {
|
||||
Create(log *AuditLog) error
|
||||
FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error)
|
||||
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||
CountEventsSince(ctx context.Context, since time.Time) (int64, error)
|
||||
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
type AuditCursor struct {
|
||||
Timestamp time.Time
|
||||
EventID string
|
||||
}
|
||||
|
||||
// RedisRepository defines interface for KV storage (Redis)
|
||||
type RedisRepository interface {
|
||||
Set(key string, value string, expiration time.Duration) error
|
||||
Get(key string) (string, error)
|
||||
Delete(key string) error
|
||||
StoreVerificationCode(phone, code string) error
|
||||
GetVerificationCode(phone string) (string, error)
|
||||
DeleteVerificationCode(phone string) error
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OathkeeperAccessLog struct {
|
||||
Timestamp time.Time
|
||||
RequestID string
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
LatencyMs int
|
||||
ClientID string
|
||||
RP string
|
||||
Action string
|
||||
Target string
|
||||
Subject string
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
Decision string
|
||||
TraceID string
|
||||
SpanID string
|
||||
Raw string
|
||||
}
|
||||
|
||||
type OathkeeperLogRepository interface {
|
||||
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RelyingParty represents an OAuth2 Client owner by a Tenant.
|
||||
// It maps 1:1 to a Hydra Client.
|
||||
type RelyingParty struct {
|
||||
ClientID string `json:"clientId"` // Maps to Hydra Client ID
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// DeletedAt removed as it's not a DB model anymore
|
||||
}
|
||||
|
||||
// TableName removed
|
||||
@@ -0,0 +1,101 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RPUsageOutboxStatusPending = "pending"
|
||||
RPUsageOutboxStatusProcessing = "processing"
|
||||
RPUsageOutboxStatusProcessed = "processed"
|
||||
RPUsageOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
RPUsageEventTypeAuthorizationGranted = "rp_usage.authorization_granted"
|
||||
RPUsageEventTypeAuthorizationRevoked = "rp_usage.authorization_revoked"
|
||||
)
|
||||
|
||||
const (
|
||||
RPUsageTenantTypeCompany = TenantTypeCompany
|
||||
RPUsageTenantTypeOrganization = TenantTypeOrganization
|
||||
)
|
||||
|
||||
type RPUsageEvent struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
EventType string `gorm:"not null;index:idx_rp_usage_outbox_event" json:"eventType"`
|
||||
Subject string `gorm:"not null;index:idx_rp_usage_outbox_subject" json:"subject"`
|
||||
TenantID string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantId,omitempty"`
|
||||
TenantType string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantType,omitempty"`
|
||||
ClientID string `gorm:"not null;index:idx_rp_usage_outbox_client" json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
SessionID string `gorm:"index" json:"sessionId,omitempty"`
|
||||
Scopes pq.StringArray `gorm:"type:text[]" json:"scopes,omitempty"`
|
||||
Source string `gorm:"not null;index" json:"source"`
|
||||
CorrelationID string `gorm:"index" json:"correlationId,omitempty"`
|
||||
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
|
||||
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (e *RPUsageEvent) TableName() string {
|
||||
return "rp_usage_outbox"
|
||||
}
|
||||
|
||||
func (e *RPUsageEvent) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == "" {
|
||||
e.ID = uuid.NewString()
|
||||
}
|
||||
if e.Status == "" {
|
||||
e.Status = RPUsageOutboxStatusPending
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
e.OccurredAt = time.Now()
|
||||
}
|
||||
if e.Payload == nil {
|
||||
e.Payload = JSONMap{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RPUsageEventSink interface {
|
||||
EmitRPUsageEvent(ctx context.Context, event RPUsageEvent) error
|
||||
}
|
||||
|
||||
type RPUsageProjectionRepository interface {
|
||||
CreateRPUsageEvent(ctx context.Context, event RPUsageEvent) error
|
||||
}
|
||||
|
||||
type RPUsageDailyMetric struct {
|
||||
Date string `json:"date"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantType string `json:"tenantType"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName"`
|
||||
LoginRequests uint64 `json:"loginRequests"`
|
||||
OtherRequests uint64 `json:"otherRequests"`
|
||||
UniqueSubjects uint64 `json:"uniqueSubjects"`
|
||||
}
|
||||
|
||||
type RPUsageQuery struct {
|
||||
Days int
|
||||
Period string
|
||||
TenantID string
|
||||
}
|
||||
|
||||
type RPUsageQueryRepository interface {
|
||||
FindRPUsage(ctx context.Context, query RPUsageQuery) ([]RPUsageDailyMetric, error)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type RPUserMetadata struct {
|
||||
ClientID string `gorm:"column:client_id;primaryKey" json:"clientId"`
|
||||
UserID string `gorm:"column:user_id;type:uuid;primaryKey" json:"userId"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (RPUserMetadata) TableName() string {
|
||||
return "rp_user_metadata"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SharedLink struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"`
|
||||
Token string `gorm:"uniqueIndex;not null" json:"token"`
|
||||
Name string `gorm:"not null" json:"name"` // 링크 식별을 위한 이름 (예: "24년 상반기 채용공고용")
|
||||
Description string `json:"description"`
|
||||
AccessLevel string `gorm:"default:'READ_ONLY'" json:"accessLevel"`
|
||||
IsActive bool `gorm:"default:true" json:"isActive"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
Password string `json:"-"` // 필요 시 비밀번호 (선택 사항)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Relation
|
||||
Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"`
|
||||
}
|
||||
|
||||
func (s *SharedLink) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if s.ID == "" {
|
||||
s.ID = uuid.NewString()
|
||||
}
|
||||
if s.Token == "" {
|
||||
// 32바이트(64자)의 강력한 난수 토큰 생성
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Token = hex.EncodeToString(b)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *SharedLink) IsValid() bool {
|
||||
if !s.IsActive {
|
||||
return false
|
||||
}
|
||||
if s.ExpiresAt != nil && s.ExpiresAt.Before(time.Now()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSharedLinkBeforeCreate(t *testing.T) {
|
||||
t.Run("generates id and token when missing", func(t *testing.T) {
|
||||
link := SharedLink{}
|
||||
|
||||
if err := link.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if link.ID == "" {
|
||||
t.Fatalf("expected generated id")
|
||||
}
|
||||
if len(link.Token) != 64 {
|
||||
t.Fatalf("expected 64-character token, got %q", link.Token)
|
||||
}
|
||||
if _, err := hex.DecodeString(link.Token); err != nil {
|
||||
t.Fatalf("expected hex token: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing id and token", func(t *testing.T) {
|
||||
link := SharedLink{
|
||||
ID: "existing-id",
|
||||
Token: "existing-token",
|
||||
}
|
||||
|
||||
if err := link.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if link.ID != "existing-id" || link.Token != "existing-token" {
|
||||
t.Fatalf("expected existing fields to be preserved: %#v", link)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSharedLinkIsValid(t *testing.T) {
|
||||
future := time.Now().Add(time.Hour)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
link SharedLink
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "active link without expiration is valid",
|
||||
link: SharedLink{IsActive: true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active link with future expiration is valid",
|
||||
link: SharedLink{IsActive: true, ExpiresAt: &future},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "inactive link is invalid",
|
||||
link: SharedLink{IsActive: false, ExpiresAt: &future},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired link is invalid",
|
||||
link: SharedLink{IsActive: true, ExpiresAt: &past},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.link.IsValid(); got != tc.expected {
|
||||
t.Fatalf("unexpected validity: got=%v expected=%v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
// SmsService defines the interface for sending SMS messages.
|
||||
type SmsService interface {
|
||||
SendSms(to, content string) error
|
||||
}
|
||||
|
||||
// NaverSmsRequest represents the request body for the Naver Cloud SMS API.
|
||||
type NaverSmsRequest struct {
|
||||
Type string `json:"type"`
|
||||
ContentType string `json:"contentType"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Messages []SmsMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// SmsMessage represents a single message to be sent.
|
||||
type SmsMessage struct {
|
||||
To string `json:"to"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// NaverSmsResponse represents the response from the Naver Cloud SMS API.
|
||||
type NaverSmsResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
RequestTime string `json:"requestTime"`
|
||||
StatusCode string `json:"statusCode"`
|
||||
StatusName string `json:"statusName"`
|
||||
}
|
||||
|
||||
// SmsRequest represents the request body for sending an SMS.
|
||||
type SmsRequest struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
}
|
||||
|
||||
// SmsVerifyRequest represents the request body for verifying an SMS code.
|
||||
type SmsVerifyRequest struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemSetting stores small global configuration documents.
|
||||
type SystemSetting struct {
|
||||
Key string `gorm:"primaryKey;size:128" json:"key"`
|
||||
Value JSONMap `gorm:"type:jsonb" json:"value"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tenant statuses
|
||||
const (
|
||||
TenantStatusPending = "pending"
|
||||
TenantStatusActive = "active"
|
||||
TenantStatusSuspended = "suspended"
|
||||
TenantStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Tenant types
|
||||
const (
|
||||
TenantTypePersonal = "PERSONAL"
|
||||
TenantTypeCompany = "COMPANY"
|
||||
TenantTypeCompanyGroup = "COMPANY_GROUP"
|
||||
TenantTypeOrganization = "ORGANIZATION"
|
||||
TenantTypeUserGroup = "USER_GROUP"
|
||||
)
|
||||
|
||||
// Tenant represents a tenant model stored in PostgreSQL.
|
||||
type Tenant struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
|
||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||
Description string `json:"description"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
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 == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TenantDomain represents a domain associated with a tenant for auto-assignment.
|
||||
type TenantDomain struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"tenantId"`
|
||||
Domain string `gorm:"not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"domain"` // e.g. "example.com"
|
||||
Verified bool `gorm:"default:false" json:"verified"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (td *TenantDomain) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if td.ID == "" {
|
||||
td.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User roles
|
||||
const (
|
||||
RoleSuperAdmin = "super_admin" // 시스템 전역 관리자
|
||||
RoleUser = "user" // 일반 사용자
|
||||
)
|
||||
|
||||
// User statuses
|
||||
const (
|
||||
UserStatusActive = "active"
|
||||
UserStatusSuspended = "suspended"
|
||||
UserStatusTemporaryLeave = "temporary_leave"
|
||||
UserStatusPreboarding = "preboarding"
|
||||
UserStatusBaronGuest = "baron_guest"
|
||||
UserStatusExtendedLeave = "extended_leave"
|
||||
UserStatusArchived = "archived"
|
||||
)
|
||||
|
||||
func NormalizeUserStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "", UserStatusActive:
|
||||
return UserStatusActive
|
||||
case "blocked", UserStatusSuspended:
|
||||
return UserStatusSuspended
|
||||
case "inactive", UserStatusPreboarding:
|
||||
return UserStatusPreboarding
|
||||
case "leave_of_absence", UserStatusTemporaryLeave:
|
||||
return UserStatusTemporaryLeave
|
||||
case "baron_only", UserStatusBaronGuest:
|
||||
return UserStatusBaronGuest
|
||||
case UserStatusExtendedLeave:
|
||||
return UserStatusExtendedLeave
|
||||
case UserStatusArchived:
|
||||
return UserStatusArchived
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
}
|
||||
|
||||
func IsBaronActivityAllowedStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusBaronGuest:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsOrgVisibleUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsWorksProvisionedUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsWorksDeprovisionUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusBaronGuest, UserStatusExtendedLeave, UserStatusArchived:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeRole maps legacy/synonym role values to canonical role keys.
|
||||
func NormalizeRole(role string) string {
|
||||
if normalized, ok := NormalizeRoleAlias(role); ok {
|
||||
return normalized
|
||||
}
|
||||
return RoleUser
|
||||
}
|
||||
|
||||
func NormalizeRoleAlias(role string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||
switch normalized {
|
||||
case RoleSuperAdmin, RoleUser:
|
||||
return normalized, true
|
||||
case "tenant_admin", "rp_admin", "tenant_member", "member", "admin", "tenantadmin", "tenant-admin":
|
||||
return RoleUser, true
|
||||
case "superadmin", "super-admin":
|
||||
return RoleSuperAdmin, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// User represents the user model stored in PostgreSQL
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash *string `gorm:"column:password_hash" json:"-"`
|
||||
Name string `gorm:"column:name;not null" json:"name"`
|
||||
Phone string `gorm:"column:phone" json:"phone"`
|
||||
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, user
|
||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||
CompanyCode string `gorm:"-" json:"companyCode,omitempty"`
|
||||
CompanyCodes pq.StringArray `gorm:"-" json:"companyCodes,omitempty"`
|
||||
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `gorm:"column:department" json:"department"`
|
||||
Grade string `gorm:"column:grade" json:"grade"` // 직급 (예: 수석, 책임, 선임)
|
||||
Position string `gorm:"column:position" json:"position"` // 직책 (예: 팀장, 센터장)
|
||||
JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
Status string `gorm:"column:status;default:'active'" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
|
||||
// Multiple identifiers support
|
||||
UserLoginIDs []UserLoginID `gorm:"foreignKey:UserID" json:"userLoginIds,omitempty"`
|
||||
}
|
||||
|
||||
// UserLoginID represents multiple custom identifiers for a user
|
||||
type UserLoginID struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
UserID string `gorm:"type:uuid;not null;index" json:"userId"`
|
||||
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"` // 발급 테넌트
|
||||
FieldKey string `gorm:"not null" json:"fieldKey"` // 스키마 필드 키 (예: emp_id)
|
||||
LoginID string `gorm:"uniqueIndex;not null" json:"loginId"` // 실제 값 (예: EMP001)
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateLoginID checks if the loginID violates any collision, length, or security rules.
|
||||
func ValidateLoginID(loginID string, emails []string, phone string) error {
|
||||
loginID = strings.TrimSpace(loginID)
|
||||
if loginID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(loginID) < 4 || len(loginID) > 30 {
|
||||
return fmt.Errorf("ID must be between 4 and 30 characters")
|
||||
}
|
||||
|
||||
if strings.Contains(loginID, "@") {
|
||||
return fmt.Errorf("ID cannot be an email format")
|
||||
}
|
||||
|
||||
for _, email := range emails {
|
||||
if email != "" && strings.EqualFold(loginID, email) {
|
||||
return fmt.Errorf("ID cannot be the same as the email address")
|
||||
}
|
||||
}
|
||||
|
||||
if phone != "" {
|
||||
normalizedPhone := NormalizePhoneNumber(phone)
|
||||
|
||||
if loginID == phone || loginID == normalizedPhone {
|
||||
return fmt.Errorf("ID cannot be the same as the phone number")
|
||||
}
|
||||
}
|
||||
|
||||
isPureNumber := true
|
||||
loginIDDigits := strings.ReplaceAll(loginID, "-", "")
|
||||
loginIDDigits = strings.ReplaceAll(loginIDDigits, " ", "")
|
||||
for _, c := range loginIDDigits {
|
||||
if (c < '0' || c > '9') && c != '+' {
|
||||
isPureNumber = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isPureNumber && len(loginIDDigits) >= 10 && len(loginIDDigits) <= 12 {
|
||||
if strings.HasPrefix(loginIDDigits, "010") || strings.HasPrefix(loginIDDigits, "82") || strings.HasPrefix(loginIDDigits, "+82") {
|
||||
return fmt.Errorf("ID cannot be a phone number format")
|
||||
}
|
||||
}
|
||||
|
||||
reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"}
|
||||
lowerID := strings.ToLower(loginID)
|
||||
if slices.Contains(reserved, lowerID) {
|
||||
return fmt.Errorf("reserved ID cannot be used")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizePhoneNumber(phone string) string {
|
||||
trimmed := strings.TrimSpace(phone)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
hasLeadingPlus := false
|
||||
digits := strings.Builder{}
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits.WriteRune(r)
|
||||
case r == '+' && digits.Len() == 0 && !hasLeadingPlus:
|
||||
hasLeadingPlus = true
|
||||
}
|
||||
}
|
||||
|
||||
number := digits.String()
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(number, "010") {
|
||||
return "+82" + number[1:]
|
||||
}
|
||||
if strings.HasPrefix(number, "82") {
|
||||
rest := number[2:]
|
||||
for strings.HasPrefix(rest, "82") {
|
||||
rest = rest[2:]
|
||||
}
|
||||
if strings.HasPrefix(rest, "0") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
return "+82" + rest
|
||||
}
|
||||
if hasLeadingPlus {
|
||||
return "+" + number
|
||||
}
|
||||
return number
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserGroup represents a collection of users within a tenant.
|
||||
type UserGroup struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"`
|
||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 상위 조직 ID
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"index" json:"slug"` // 추가
|
||||
Description string `json:"description"`
|
||||
UnitType string `json:"unitType"` // 부, 국, 팀, 셀 등
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Relationships
|
||||
Parent *UserGroup `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Members []User `gorm:"-" json:"members,omitempty"`
|
||||
}
|
||||
|
||||
type GroupCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Description string `json:"description"`
|
||||
UnitType string `json:"unitType"`
|
||||
}
|
||||
|
||||
type GroupRole struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
|
||||
func (ug *UserGroup) TableName() string {
|
||||
return "user_groups"
|
||||
}
|
||||
|
||||
func (ug *UserGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if ug.ID == "" {
|
||||
ug.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
UserProjectionNameKratos = "kratos_users"
|
||||
|
||||
UserProjectionStatusSyncing = "syncing"
|
||||
UserProjectionStatusReady = "ready"
|
||||
UserProjectionStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type UserProjectionState struct {
|
||||
Name string `gorm:"primaryKey;column:name" json:"name"`
|
||||
Status string `gorm:"column:status;not null" json:"status"`
|
||||
LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"`
|
||||
LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserProjectionStatus struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Ready bool `json:"ready"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
ProjectedUsers int64 `json:"projectedUsers"`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "super admin unchanged", in: "super_admin", want: RoleSuperAdmin},
|
||||
{name: "tenant admin mapped to user", in: "tenant_admin", want: RoleUser},
|
||||
{name: "rp admin mapped to user", in: "rp_admin", want: RoleUser},
|
||||
{name: "user unchanged", in: "user", want: RoleUser},
|
||||
{name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin},
|
||||
{name: "super admin compact alias", in: "superadmin", want: RoleSuperAdmin},
|
||||
{name: "legacy admin mapped to user", in: "admin", want: RoleUser},
|
||||
{name: "legacy tenant member", in: "tenant_member", want: RoleUser},
|
||||
{name: "trim and lower", in: " ADMIN ", want: RoleUser},
|
||||
{name: "unknown role mapped to user", in: "custom_role", want: RoleUser},
|
||||
{name: "empty string mapped to user", in: " ", want: RoleUser},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := NormalizeRole(tc.in); got != tc.want {
|
||||
t.Fatalf("NormalizeRole(%q)=%q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserStatusPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
normalized string
|
||||
baronAllowed bool
|
||||
orgVisible bool
|
||||
worksProvisioned bool
|
||||
worksDeprovisioned bool
|
||||
}{
|
||||
{status: UserStatusActive, normalized: UserStatusActive, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||
{status: UserStatusTemporaryLeave, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||
{status: UserStatusSuspended, normalized: UserStatusSuspended, orgVisible: true, worksProvisioned: true},
|
||||
{status: UserStatusPreboarding, normalized: UserStatusPreboarding},
|
||||
{status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
||||
{status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true},
|
||||
{status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true},
|
||||
{status: "inactive", normalized: UserStatusPreboarding},
|
||||
{status: "leave_of_absence", normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||
{status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.status, func(t *testing.T) {
|
||||
if got := NormalizeUserStatus(tc.status); got != tc.normalized {
|
||||
t.Fatalf("NormalizeUserStatus(%q)=%q, want %q", tc.status, got, tc.normalized)
|
||||
}
|
||||
if got := IsBaronActivityAllowedStatus(tc.status); got != tc.baronAllowed {
|
||||
t.Fatalf("IsBaronActivityAllowedStatus(%q)=%v, want %v", tc.status, got, tc.baronAllowed)
|
||||
}
|
||||
if got := IsOrgVisibleUserStatus(tc.status); got != tc.orgVisible {
|
||||
t.Fatalf("IsOrgVisibleUserStatus(%q)=%v, want %v", tc.status, got, tc.orgVisible)
|
||||
}
|
||||
if got := IsWorksProvisionedUserStatus(tc.status); got != tc.worksProvisioned {
|
||||
t.Fatalf("IsWorksProvisionedUserStatus(%q)=%v, want %v", tc.status, got, tc.worksProvisioned)
|
||||
}
|
||||
if got := IsWorksDeprovisionUserStatus(tc.status); got != tc.worksDeprovisioned {
|
||||
t.Fatalf("IsWorksDeprovisionUserStatus(%q)=%v, want %v", tc.status, got, tc.worksDeprovisioned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateLoginID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loginID string
|
||||
emails []string
|
||||
phone string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Empty", "", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Valid alphanumeric", "user123", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Too short", "us", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Too long", "thisisaverylongloginidthatiswayoverthirtycharacters", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Email format", "user@domain.com", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Exact email match", "Test@Email.Com", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Secondary email match", "sub@test.com", []string{"test@email.com", "sub@test.com"}, "01012345678", true},
|
||||
{"Phone number match", "010-1234-5678", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Phone number match +82", "+821012345678", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Phone number match digits", "01012345678", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Phone format (11 digits)", "01098765432", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Valid pure digits (employee ID)", "20230001", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Valid pure digits long", "123456789", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Valid pure digits 10 chars", "1234567890", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Reserved word admin", "ADMIN", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Reserved word root", "root", []string{"test@email.com"}, "01012345678", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateLoginID(tt.loginID, tt.emails, tt.phone)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateLoginID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneNumberDeduplicatesKoreanCountryCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Local mobile", "010-9191-7771", "+821091917771"},
|
||||
{"Korean country code", "+82 10-9191-7771", "+821091917771"},
|
||||
{"Duplicate plus Korean country code", "+82 +821091917771", "+821091917771"},
|
||||
{"Duplicate compact Korean country code", "+82821091917771", "+821091917771"},
|
||||
{"Duplicate spaced Korean country code", "+82 8210 9191 7771", "+821091917771"},
|
||||
{"Non Korean international phone preserved", "+1 914 481 2222", "+19144812222"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := NormalizePhoneNumber(tt.input); got != tt.want {
|
||||
t.Fatalf("NormalizePhoneNumber(%q)=%q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileOutboxStatusPending = "pending"
|
||||
WorksmobileOutboxStatusProcessing = "processing"
|
||||
WorksmobileOutboxStatusProcessed = "processed"
|
||||
WorksmobileOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileResourceOrgUnit = "ORGUNIT"
|
||||
WorksmobileResourceUser = "USER"
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileActionUpsert = "UPSERT"
|
||||
WorksmobileActionDelete = "DELETE"
|
||||
WorksmobileActionDryRun = "DRY_RUN"
|
||||
WorksmobileActionSuspend = "SUSPEND"
|
||||
WorksmobileActionPasswordReset = "PASSWORD_RESET"
|
||||
)
|
||||
|
||||
type WorksmobileOutbox struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
ResourceType string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceType"`
|
||||
ResourceID string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceId"`
|
||||
Action string `gorm:"not null" json:"action"`
|
||||
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
|
||||
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (w *WorksmobileOutbox) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.ID == "" {
|
||||
w.ID = uuid.NewString()
|
||||
}
|
||||
if w.Status == "" {
|
||||
w.Status = WorksmobileOutboxStatusPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorksmobileResourceMapping struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
BaronResourceType string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceType"`
|
||||
BaronResourceID string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceId"`
|
||||
ExternalKey string `gorm:"not null;uniqueIndex" json:"externalKey"`
|
||||
WorksmobileResourceID string `json:"worksmobileResourceId,omitempty"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (w *WorksmobileResourceMapping) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.ID == "" {
|
||||
w.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adminHydraClientLister interface {
|
||||
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
|
||||
}
|
||||
|
||||
type identityCacheAdmin interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error)
|
||||
}
|
||||
|
||||
type AdminHandler struct {
|
||||
DB *gorm.DB
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
IdentityCache identityCacheAdmin
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
}
|
||||
|
||||
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
|
||||
|
||||
type globalCustomClaimDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
ValueType string `json:"valueType"`
|
||||
ReadPermission string `json:"readPermission"`
|
||||
WritePermission string `json:"writePermission"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type globalCustomClaimDefinitionsResponse struct {
|
||||
Items []globalCustomClaimDefinition `json:"items"`
|
||||
}
|
||||
|
||||
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
Keto: keto,
|
||||
KetoOutbox: ketoOutbox,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error {
|
||||
if h == nil || h.RPUsageQueries == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "rp usage query service unavailable",
|
||||
})
|
||||
}
|
||||
days := 14
|
||||
if raw := c.Query("days"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
period := normalizeRPUsagePeriod(c.Query("period"))
|
||||
tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId")))
|
||||
if !allowed {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "forbidden: tenant rp usage stats permission denied",
|
||||
})
|
||||
}
|
||||
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
|
||||
Days: days,
|
||||
Period: period,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"days": days,
|
||||
"period": period,
|
||||
"tenantId": tenantID,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeRPUsagePeriod(period string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(period)) {
|
||||
case "week":
|
||||
return "week"
|
||||
case "month":
|
||||
return "month"
|
||||
default:
|
||||
return "day"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
|
||||
return requestedTenantID, true
|
||||
}
|
||||
tenantID := requestedTenantID
|
||||
if tenantID == "" && profile != nil && profile.TenantID != nil {
|
||||
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
if tenantID == "" {
|
||||
return "", false
|
||||
}
|
||||
if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" {
|
||||
return "", false
|
||||
}
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats")
|
||||
if err != nil || !allowed {
|
||||
return "", false
|
||||
}
|
||||
return tenantID, true
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetGlobalCustomClaimDefinitions(c *fiber.Ctx) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "settings store unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
var setting domain.SystemSetting
|
||||
if err := h.DB.WithContext(c.Context()).First(&setting, "key = ?", globalCustomClaimsSettingKey).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{Items: []globalCustomClaimDefinition{}})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{
|
||||
Items: normalizeGlobalCustomClaimDefinitions(setting.Value["items"]),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateGlobalCustomClaimDefinitions(c *fiber.Ctx) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "settings store unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
var req globalCustomClaimDefinitionsResponse
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
items, err := validateGlobalCustomClaimDefinitions(req.Items)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
setting := domain.SystemSetting{
|
||||
Key: globalCustomClaimsSettingKey,
|
||||
Value: domain.JSONMap{"items": globalCustomClaimDefinitionsToJSON(items)},
|
||||
}
|
||||
if err := h.DB.WithContext(c.Context()).Save(&setting).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{Items: items})
|
||||
}
|
||||
|
||||
func normalizeGlobalCustomClaimDefinitions(value any) []globalCustomClaimDefinition {
|
||||
rawItems, ok := value.([]any)
|
||||
if !ok {
|
||||
return []globalCustomClaimDefinition{}
|
||||
}
|
||||
items := make([]globalCustomClaimDefinition, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
raw, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
def := globalCustomClaimDefinition{
|
||||
Key: strings.TrimSpace(stringValue(raw["key"])),
|
||||
Label: strings.TrimSpace(stringValue(raw["label"])),
|
||||
ValueType: normalizeGlobalCustomClaimType(stringValue(raw["valueType"])),
|
||||
ReadPermission: adminNormalizeCustomClaimPermission(stringValue(raw["readPermission"])),
|
||||
WritePermission: adminNormalizeCustomClaimPermission(stringValue(raw["writePermission"])),
|
||||
Description: strings.TrimSpace(stringValue(raw["description"])),
|
||||
}
|
||||
if def.Key != "" {
|
||||
items = append(items, def)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func validateGlobalCustomClaimDefinitions(items []globalCustomClaimDefinition) ([]globalCustomClaimDefinition, error) {
|
||||
seen := map[string]struct{}{}
|
||||
normalized := make([]globalCustomClaimDefinition, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !isValidCustomClaimKey(key) {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "claim key must use letters, numbers, underscore, dot, or hyphen")
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "duplicate claim key: "+key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, globalCustomClaimDefinition{
|
||||
Key: key,
|
||||
Label: strings.TrimSpace(item.Label),
|
||||
ValueType: normalizeGlobalCustomClaimType(item.ValueType),
|
||||
ReadPermission: adminNormalizeCustomClaimPermission(item.ReadPermission),
|
||||
WritePermission: adminNormalizeCustomClaimPermission(item.WritePermission),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
})
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func globalCustomClaimDefinitionsToJSON(items []globalCustomClaimDefinition) []any {
|
||||
values := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
values = append(values, map[string]any{
|
||||
"key": item.Key,
|
||||
"label": item.Label,
|
||||
"valueType": item.ValueType,
|
||||
"readPermission": item.ReadPermission,
|
||||
"writePermission": item.WritePermission,
|
||||
"description": item.Description,
|
||||
})
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func normalizeGlobalCustomClaimType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "number", "boolean", "array", "object", "date", "datetime":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "text"
|
||||
}
|
||||
}
|
||||
|
||||
func adminNormalizeCustomClaimPermission(value string) string {
|
||||
if strings.TrimSpace(value) == "user_and_admin" {
|
||||
return "user_and_admin"
|
||||
}
|
||||
return "admin_only"
|
||||
}
|
||||
|
||||
func isValidCustomClaimKey(value string) bool {
|
||||
for _, r := range value {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requireSuperAdminProfile(c *fiber.Ctx) bool {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
_ = c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
}
|
||||
status, err := h.UserProjectionRepo.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(status)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
}
|
||||
projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
cacheStatus := domain.IdentityCacheStatus{
|
||||
Status: "unavailable",
|
||||
RedisReady: false,
|
||||
LastError: "identity cache service unavailable",
|
||||
}
|
||||
if h.IdentityCache != nil {
|
||||
cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"userProjection": projectionStatus,
|
||||
"identityCache": cacheStatus,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) FlushIdentityCache(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity cache service unavailable"})
|
||||
}
|
||||
result, err := h.IdentityCache.FlushIdentityCache(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IntegrityChecker == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||
}
|
||||
report, err := h.IntegrityChecker.CheckDataIntegrity(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(report)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListOrphanUserLoginIDs(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IntegrityChecker == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||
}
|
||||
items, err := h.IntegrityChecker.ListOrphanUserLoginIDs(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteOrphanUserLoginIDs(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IntegrityChecker == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||
}
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
result, err := h.IntegrityChecker.DeleteOrphanUserLoginIDs(c.Context(), req.IDs)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
// GetSystemStats returns runtime statistics for monitoring
|
||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
ctx := c.Context()
|
||||
|
||||
stats := fiber.Map{
|
||||
"totalTenants": h.countTenants(ctx),
|
||||
"totalUsers": h.countUsers(ctx),
|
||||
"oidcClients": h.countOIDCClients(ctx),
|
||||
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"cpus": runtime.NumCPU(),
|
||||
"memory": fiber.Map{
|
||||
"alloc": m.Alloc,
|
||||
"totalAlign": m.TotalAlloc,
|
||||
"sys": m.Sys,
|
||||
"numGC": m.NumGC,
|
||||
},
|
||||
"timestamp": time.Now(),
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(stats)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
||||
if h == nil || h.TenantRepo == nil {
|
||||
return 0
|
||||
}
|
||||
_, total, err := h.TenantRepo.List(ctx, 1, 0, "", "")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countUsers(ctx context.Context) int64 {
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return 0
|
||||
}
|
||||
status, err := h.UserProjectionRepo.GetStatus(ctx)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return status.ProjectedUsers
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
|
||||
if h == nil || h.Hydra == nil {
|
||||
return 0
|
||||
}
|
||||
const pageSize = 500
|
||||
var total int64
|
||||
for offset := 0; ; offset += pageSize {
|
||||
clients, err := h.Hydra.ListClients(ctx, pageSize, offset)
|
||||
if err != nil {
|
||||
return total
|
||||
}
|
||||
for _, client := range clients {
|
||||
if isHiddenSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
}
|
||||
if len(clients) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 {
|
||||
if h == nil || h.AuditRepo == nil {
|
||||
return 0
|
||||
}
|
||||
count, err := h.AuditRepo.CountEventsSince(ctx, since)
|
||||
if err == nil && count > 0 {
|
||||
return count
|
||||
}
|
||||
logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "")
|
||||
if pageErr != nil {
|
||||
return count
|
||||
}
|
||||
var fallbackCount int64
|
||||
for _, log := range logs {
|
||||
if !log.Timestamp.Before(since) {
|
||||
fallbackCount++
|
||||
}
|
||||
}
|
||||
return fallbackCount
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeRPUsageQueryRepo struct {
|
||||
query domain.RPUsageQuery
|
||||
items []domain.RPUsageDailyMetric
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageQueryRepo) FindRPUsage(ctx context.Context, query domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) {
|
||||
f.query = query
|
||||
return f.items, nil
|
||||
}
|
||||
|
||||
type fakeAdminKeto struct {
|
||||
allowed bool
|
||||
subject string
|
||||
object string
|
||||
relation string
|
||||
}
|
||||
|
||||
func (f *fakeAdminKeto) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||
f.subject = subject
|
||||
f.object = object
|
||||
f.relation = relation
|
||||
return f.allowed, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminKeto) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminKeto) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminKeto) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminKeto) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeOverviewAuditRepo struct {
|
||||
mockAuditRepo
|
||||
since time.Time
|
||||
count int64
|
||||
}
|
||||
|
||||
func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
|
||||
f.since = since
|
||||
return f.count, nil
|
||||
}
|
||||
|
||||
type fakeAdminUserProjectionRepo struct {
|
||||
status domain.UserProjectionStatus
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
|
||||
return f.status.Ready, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
type fakeIdentityCacheAdmin struct {
|
||||
status domain.IdentityCacheStatus
|
||||
flush domain.IdentityCacheFlushResult
|
||||
err error
|
||||
statusHit int
|
||||
flushCalls int
|
||||
}
|
||||
|
||||
func (f *fakeIdentityCacheAdmin) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
f.statusHit++
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func (f *fakeIdentityCacheAdmin) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
f.flushCalls++
|
||||
return f.flush, f.err
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
|
||||
repo := &fakeRPUsageQueryRepo{
|
||||
items: []domain.RPUsageDailyMetric{
|
||||
{
|
||||
Date: "2026-05-06",
|
||||
TenantID: "tenant-1",
|
||||
TenantType: domain.TenantTypeCompany,
|
||||
ClientID: "orgfront",
|
||||
ClientName: "OrgFront",
|
||||
LoginRequests: 12,
|
||||
OtherRequests: 4,
|
||||
UniqueSubjects: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{RPUsageQueries: repo}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?days=7&period=week&tenantId=tenant-1", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 7, repo.query.Days)
|
||||
require.Equal(t, "week", repo.query.Period)
|
||||
require.Equal(t, "tenant-1", repo.query.TenantID)
|
||||
|
||||
var body struct {
|
||||
Items []domain.RPUsageDailyMetric `json:"items"`
|
||||
Days int `json:"days"`
|
||||
Period string `json:"period"`
|
||||
TenantID string `json:"tenantId"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, 7, body.Days)
|
||||
require.Equal(t, "week", body.Period)
|
||||
require.Equal(t, "tenant-1", body.TenantID)
|
||||
require.Len(t, body.Items, 1)
|
||||
require.Equal(t, "orgfront", body.Items[0].ClientID)
|
||||
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
|
||||
}
|
||||
|
||||
func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
LastSyncedAt: &syncedAt,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body domain.UserProjectionStatus
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, domain.UserProjectionNameKratos, body.Name)
|
||||
require.Equal(t, domain.UserProjectionStatusReady, body.Status)
|
||||
require.True(t, body.Ready)
|
||||
require.Equal(t, int64(152), body.ProjectedUsers)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
status: domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
ObservedCount: 151,
|
||||
KeyCount: 153,
|
||||
LastRefreshedAt: &syncedAt,
|
||||
UpdatedAt: &syncedAt,
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
LastSyncedAt: &syncedAt,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
IdentityCache: cache,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/ory/ssot", h.GetOrySSOTSystemStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ory/ssot", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
UserProjection domain.UserProjectionStatus `json:"userProjection"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(152), body.UserProjection.ProjectedUsers)
|
||||
require.True(t, body.IdentityCache.RedisReady)
|
||||
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
||||
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
||||
require.Equal(t, 1, cache.statusHit)
|
||||
}
|
||||
|
||||
func TestAdminHandler_FlushIdentityCacheRequiresSuperAdminAndFlushesCacheOnly(t *testing.T) {
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
flush: domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: 7,
|
||||
UpdatedAt: time.Date(2026, 5, 11, 3, 2, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
IdentityCache: cache,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/admin/ory/ssot/identity-cache/flush", h.FlushIdentityCache)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ory/ssot/identity-cache/flush", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body domain.IdentityCacheFlushResult
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(7), body.FlushedKeys)
|
||||
require.Equal(t, 1, cache.flushCalls)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
|
||||
repo := &fakeRPUsageQueryRepo{}
|
||||
keto := &fakeAdminKeto{allowed: true}
|
||||
h := &AdminHandler{RPUsageQueries: repo, Keto: keto}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: "tenant_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?tenantId=tenant-allowed", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "User:user-1", keto.subject)
|
||||
require.Equal(t, "tenant-allowed", keto.object)
|
||||
require.Equal(t, "view_rp_usage_stats", keto.relation)
|
||||
require.Equal(t, "tenant-allowed", repo.query.TenantID)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
|
||||
auditRepo := &fakeOverviewAuditRepo{count: 22}
|
||||
h := &AdminHandler{
|
||||
AuditRepo: auditRepo,
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/admin/stats", h.GetSystemStats)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Contains(t, body, "totalTenants")
|
||||
require.Contains(t, body, "totalUsers")
|
||||
require.Contains(t, body, "oidcClients")
|
||||
require.Contains(t, body, "auditEvents24h")
|
||||
require.Equal(t, float64(152), body["totalUsers"])
|
||||
require.Equal(t, float64(22), body["auditEvents24h"])
|
||||
require.Equal(t, time.UTC, auditRepo.since.Location())
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeDataIntegrityChecker struct {
|
||||
calls int
|
||||
listCalls int
|
||||
deleteCalls int
|
||||
deletedIDs []string
|
||||
report domain.DataIntegrityReport
|
||||
orphans []domain.OrphanUserLoginID
|
||||
deleteResult domain.DeleteOrphanUserLoginIDsResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
||||
f.calls++
|
||||
return f.report, f.err
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
|
||||
f.listCalls++
|
||||
return f.orphans, f.err
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||
f.deleteCalls++
|
||||
f.deletedIDs = append([]string(nil), ids...)
|
||||
return f.deleteResult, f.err
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Equal(t, 0, checker.calls)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
|
||||
checkedAt := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC)
|
||||
checker := &fakeDataIntegrityChecker{
|
||||
report: domain.DataIntegrityReport{
|
||||
Status: domain.DataIntegrityStatusFail,
|
||||
CheckedAt: checkedAt,
|
||||
Summary: domain.DataIntegritySummary{
|
||||
TotalChecks: 1,
|
||||
Failures: 1,
|
||||
},
|
||||
Sections: []domain.DataIntegritySection{
|
||||
{
|
||||
Key: "tenant_integrity",
|
||||
Label: "테넌트 정합성",
|
||||
Status: domain.DataIntegrityStatusFail,
|
||||
Checks: []domain.DataIntegrityCheck{
|
||||
{
|
||||
Key: "duplicate_tenant_slugs",
|
||||
Label: "중복 테넌트 slug",
|
||||
Status: domain.DataIntegrityStatusFail,
|
||||
Count: 1,
|
||||
Severity: "error",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, checker.calls)
|
||||
|
||||
var body domain.DataIntegrityReport
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, domain.DataIntegrityStatusFail, body.Status)
|
||||
require.Equal(t, int64(1), body.Summary.Failures)
|
||||
require.Len(t, body.Sections, 1)
|
||||
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
|
||||
}
|
||||
|
||||
func TestAdminHandler_ListOrphanUserLoginIDsReturnsTargetsForSuperAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{
|
||||
orphans: []domain.OrphanUserLoginID{
|
||||
{
|
||||
ID: "login-id-1",
|
||||
UserID: "user-1",
|
||||
TenantID: "tenant-1",
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "EMP001",
|
||||
Reasons: []string{"missing_tenant"},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity/orphan-user-login-ids", h.ListOrphanUserLoginIDs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity/orphan-user-login-ids", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, checker.listCalls)
|
||||
|
||||
var body struct {
|
||||
Items []domain.OrphanUserLoginID `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, 1, body.Total)
|
||||
require.Equal(t, "login-id-1", body.Items[0].ID)
|
||||
require.Equal(t, []string{"missing_tenant"}, body.Items[0].Reasons)
|
||||
}
|
||||
|
||||
func TestAdminHandler_DeleteOrphanUserLoginIDsRequiresSuperAdminAndDeletesSelectedTargets(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{
|
||||
deleteResult: domain.DeleteOrphanUserLoginIDsResult{
|
||||
DeletedCount: 1,
|
||||
Deleted: []domain.OrphanUserLoginID{
|
||||
{ID: "login-id-1", LoginID: "EMP001", Reasons: []string{"missing_user"}},
|
||||
},
|
||||
SkippedIDs: []string{"valid-login-id"},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1","valid-login-id"]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, checker.deleteCalls)
|
||||
require.Equal(t, []string{"login-id-1", "valid-login-id"}, checker.deletedIDs)
|
||||
|
||||
var body domain.DeleteOrphanUserLoginIDsResult
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(1), body.DeletedCount)
|
||||
require.Equal(t, []string{"valid-login-id"}, body.SkippedIDs)
|
||||
}
|
||||
|
||||
func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1"]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Equal(t, 0, checker.deleteCalls)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/pagination"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ApiKeyHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewApiKeyHandler(db *gorm.DB) *ApiKeyHandler {
|
||||
return &ApiKeyHandler{DB: db}
|
||||
}
|
||||
|
||||
type apiKeySummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Status string `json:"status"`
|
||||
LastUsedAt *string `json:"lastUsedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type apiKeyListResponse struct {
|
||||
Items []apiKeySummary `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
func apiKeyToSummary(k domain.ApiKey) apiKeySummary {
|
||||
lastUsed := ""
|
||||
if k.LastUsedAt != nil {
|
||||
lastUsed = k.LastUsedAt.Format(time.RFC3339)
|
||||
}
|
||||
return apiKeySummary{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
ClientID: k.ClientID,
|
||||
Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")),
|
||||
Status: k.Status,
|
||||
LastUsedAt: &lastUsed,
|
||||
CreatedAt: k.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyWithUpdatedScopes(k domain.ApiKey, scopes []string) domain.ApiKey {
|
||||
k.Scopes = strings.Join(normalizeApiKeyScopes(scopes), " ")
|
||||
return k
|
||||
}
|
||||
|
||||
func apiKeyWithRotatedSecretHash(k domain.ApiKey, hashedSecret string) domain.ApiKey {
|
||||
k.ClientSecretHash = hashedSecret
|
||||
return k
|
||||
}
|
||||
|
||||
func normalizeApiKeyScopes(scopes []string) []string {
|
||||
seen := make(map[string]struct{}, len(scopes))
|
||||
normalized := make([]string, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[scope]; exists {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
normalized = append(normalized, scope)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
var keys []domain.ApiKey
|
||||
query := h.DB.Order("created_at desc, id desc").Limit(limit + 1)
|
||||
if cursorRaw != "" {
|
||||
cursor, err := pagination.Decode(cursorRaw)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
query = pagination.ApplyCreatedAtIDCursor(query, cursor, "created_at", "id")
|
||||
offset = 0
|
||||
} else {
|
||||
query = query.Offset(offset)
|
||||
}
|
||||
|
||||
if err := query.Find(&keys).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
hasMore := len(keys) > limit
|
||||
if len(keys) > limit {
|
||||
keys = keys[:limit]
|
||||
}
|
||||
if cursorRaw == "" && total > int64(offset+len(keys)) {
|
||||
hasMore = true
|
||||
}
|
||||
if hasMore && len(keys) > 0 {
|
||||
last := keys[len(keys)-1]
|
||||
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
||||
}
|
||||
|
||||
items := make([]apiKeySummary, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
items = append(items, apiKeyToSummary(k))
|
||||
}
|
||||
|
||||
return c.JSON(apiKeyListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
||||
}
|
||||
req.Scopes = normalizeApiKeyScopes(req.Scopes)
|
||||
if len(req.Scopes) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
|
||||
}
|
||||
|
||||
// Generate Client ID (16 chars hex)
|
||||
clientID := GenerateSecureToken(8)
|
||||
|
||||
// Generate plain secret (16 chars hex)
|
||||
plainSecret := GenerateSecureToken(8)
|
||||
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret")
|
||||
}
|
||||
|
||||
apiKey := domain.ApiKey{
|
||||
Name: req.Name,
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: string(hashedSecret),
|
||||
Scopes: strings.Join(req.Scopes, " "),
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&apiKey).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Return summary + PLAIN SECRET (only this time)
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"apiKey": apiKeyToSummary(apiKey),
|
||||
"clientSecret": plainSecret, // VERY IMPORTANT: user must save this now
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) UpdateApiKey(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
id := c.Params("id")
|
||||
if id == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
req.Scopes = normalizeApiKeyScopes(req.Scopes)
|
||||
if len(req.Scopes) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
|
||||
}
|
||||
|
||||
var apiKey domain.ApiKey
|
||||
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "api key not found")
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
apiKey = apiKeyWithUpdatedScopes(apiKey, req.Scopes)
|
||||
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("scopes", apiKey.Scopes).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(apiKeyToSummary(apiKey))
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) RotateApiKeySecret(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
id := c.Params("id")
|
||||
if id == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
||||
}
|
||||
|
||||
var apiKey domain.ApiKey
|
||||
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "api key not found")
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
plainSecret := GenerateSecureToken(8)
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret")
|
||||
}
|
||||
|
||||
apiKey = apiKeyWithRotatedSecretHash(apiKey, string(hashedSecret))
|
||||
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("client_secret_hash", apiKey.ClientSecretHash).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"apiKey": apiKeyToSummary(apiKey),
|
||||
"clientSecret": plainSecret,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
id := c.Params("id")
|
||||
if id == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
||||
}
|
||||
|
||||
if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Mock DB for ApiKey tests using a real GORM instance but with a hijacked connection
|
||||
// or just a simple mock if we only check nil.
|
||||
// For ApiKeyHandler, it uses DB for Create/List/Delete.
|
||||
|
||||
func TestApiKeyHandler_CreateApiKey(t *testing.T) {
|
||||
app := fiber.New()
|
||||
// ApiKeyHandler requires a valid DB connection to perform h.DB.Create
|
||||
// Since we don't have a real DB here, we'll check if it fails gracefully
|
||||
// or we can use sqlite in-memory for a more realistic test.
|
||||
h := &ApiKeyHandler{DB: nil} // Testing ServiceUnavailable
|
||||
|
||||
app.Post("/api-keys", h.CreateApiKey)
|
||||
|
||||
input := map[string]any{
|
||||
"name": "M2M Test",
|
||||
"scopes": []string{"read", "write"},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api-keys", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestApiKeyHandler_Validation(t *testing.T) {
|
||||
app := fiber.New()
|
||||
// Using a dummy DB pointer to pass the nil check
|
||||
h := &ApiKeyHandler{DB: &gorm.DB{}}
|
||||
|
||||
app.Post("/api-keys", h.CreateApiKey)
|
||||
|
||||
// Missing name
|
||||
input := map[string]any{
|
||||
"scopes": []string{"read"},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api-keys", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestApiKeyHandler_UpdateApiKeyScopesRequiresDatabase(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := &ApiKeyHandler{DB: nil}
|
||||
|
||||
app.Patch("/api-keys/:id", h.UpdateApiKey)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"scopes": []string{"org-context:read"},
|
||||
})
|
||||
req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestApiKeyHandler_RotateApiKeySecretRequiresDatabase(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := &ApiKeyHandler{DB: nil}
|
||||
|
||||
app.Post("/api-keys/:id/secret/rotate", h.RotateApiKeySecret)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api-keys/api-key-id/secret/rotate", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestApiKeyWithUpdatedScopesPreservesClientID(t *testing.T) {
|
||||
key := domain.ApiKey{
|
||||
ID: "api-key-id",
|
||||
Name: "M2M Test",
|
||||
ClientID: "client-id-stable",
|
||||
ClientSecretHash: "old-secret-hash",
|
||||
Scopes: "audit:read",
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
updated := apiKeyWithUpdatedScopes(key, []string{"audit:read", "org-context:read"})
|
||||
|
||||
assert.Equal(t, "client-id-stable", updated.ClientID)
|
||||
assert.Equal(t, "old-secret-hash", updated.ClientSecretHash)
|
||||
assert.Equal(t, "audit:read org-context:read", updated.Scopes)
|
||||
}
|
||||
|
||||
func TestApiKeyWithRotatedSecretHashPreservesClientIDAndScopes(t *testing.T) {
|
||||
key := domain.ApiKey{
|
||||
ID: "api-key-id",
|
||||
Name: "M2M Test",
|
||||
ClientID: "client-id-stable",
|
||||
ClientSecretHash: "old-secret-hash",
|
||||
Scopes: "audit:read org-context:read",
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
updated := apiKeyWithRotatedSecretHash(key, "new-secret-hash")
|
||||
|
||||
assert.Equal(t, "client-id-stable", updated.ClientID)
|
||||
assert.Equal(t, "audit:read org-context:read", updated.Scopes)
|
||||
assert.Equal(t, "new-secret-hash", updated.ClientSecretHash)
|
||||
}
|
||||
|
||||
func TestNormalizeApiKeyScopesTrimsAndDeduplicates(t *testing.T) {
|
||||
scopes := normalizeApiKeyScopes([]string{
|
||||
" audit:read ",
|
||||
"",
|
||||
"org-context:read",
|
||||
"audit:read",
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{"audit:read", "org-context:read"}, scopes)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuditHandler struct {
|
||||
repo domain.AuditRepository
|
||||
}
|
||||
|
||||
func NewAuditHandler(repo domain.AuditRepository) *AuditHandler {
|
||||
return &AuditHandler{repo: repo}
|
||||
}
|
||||
|
||||
// CreateLog handles POST /api/v1/audit
|
||||
func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
||||
var req domain.AuditLog
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Cannot parse JSON")
|
||||
}
|
||||
|
||||
// Auto-fill metadata if missing
|
||||
if req.IPAddress == "" {
|
||||
req.IPAddress = c.IP()
|
||||
}
|
||||
if req.UserAgent == "" {
|
||||
req.UserAgent = c.Get("User-Agent")
|
||||
}
|
||||
if req.Timestamp.IsZero() {
|
||||
req.Timestamp = time.Now()
|
||||
}
|
||||
if req.EventID == "" {
|
||||
req.EventID = ensureRequestID(c)
|
||||
}
|
||||
|
||||
if h.repo == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||
}
|
||||
|
||||
if err := h.repo.Create(&req); err != nil {
|
||||
// Log internal error but don't expose details
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to save audit log")
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": "Audit log saved",
|
||||
})
|
||||
}
|
||||
|
||||
// ListLogs handles GET /api/v1/audit
|
||||
func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 50)
|
||||
cursorRaw := c.Query("cursor")
|
||||
requestedTenantID := c.Query("tenantId")
|
||||
|
||||
cursor, err := parseAuditCursor(cursorRaw)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
|
||||
}
|
||||
|
||||
if h.repo == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||
}
|
||||
|
||||
// [New] Role-based Filtering
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
var filterTenantID string
|
||||
|
||||
if profile != nil {
|
||||
if profile.Role == domain.RoleSuperAdmin {
|
||||
// Super Admin can see everything or filter by a specific tenant if requested
|
||||
filterTenantID = requestedTenantID
|
||||
} else {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor, filterTenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs")
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if len(logs) > limit {
|
||||
last := logs[limit-1]
|
||||
nextCursor = encodeAuditCursor(last)
|
||||
logs = logs[:limit]
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"items": logs,
|
||||
"limit": limit,
|
||||
"cursor": cursorRaw,
|
||||
"next_cursor": nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
func ensureRequestID(c *fiber.Ctx) string {
|
||||
reqID := c.Get("X-Request-Id")
|
||||
if reqID == "" {
|
||||
reqID = uuid.New().String()
|
||||
c.Set("X-Request-Id", reqID)
|
||||
}
|
||||
return reqID
|
||||
}
|
||||
|
||||
func parseAuditCursor(raw string) (*domain.AuditCursor, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("invalid cursor")
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339Nano, parts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain.AuditCursor{
|
||||
Timestamp: ts,
|
||||
EventID: parts[1],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeAuditCursor(log domain.AuditLog) string {
|
||||
payload := log.Timestamp.UTC().Format(time.RFC3339Nano) + "|" + log.EventID
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Async Test Mocks ---
|
||||
|
||||
type AsyncMockIdpProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) Name() string { return "mock-idp" }
|
||||
func (m *AsyncMockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
return &domain.IDPMetadata{}, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) UserExists(loginID string) (bool, error) {
|
||||
args := m.Called(loginID)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return &domain.PasswordPolicy{MinLength: 12}, nil
|
||||
}
|
||||
func (m *AsyncMockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return nil }
|
||||
func (m *AsyncMockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type AsyncMockUserRepo struct {
|
||||
mock.Mock
|
||||
createCalled chan bool
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error {
|
||||
// Simulate DB latency
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
args := m.Called(ctx, user)
|
||||
if m.createCalled != nil {
|
||||
m.createCalled <- true
|
||||
}
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||
args := m.Called(ctx, user)
|
||||
if m.createCalled != nil {
|
||||
m.createCalled <- true
|
||||
}
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Get(0).([]domain.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
||||
args := m.Called(ctx, codes)
|
||||
return args.Get(0).([]domain.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) DB() *gorm.DB {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type AsyncMockRedisRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AsyncMockRedisRepo) Set(key string, value string, expiration time.Duration) error {
|
||||
args := m.Called(key, value, expiration)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AsyncMockRedisRepo) Get(key string) (string, error) {
|
||||
args := m.Called(key)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockRedisRepo) Delete(key string) error {
|
||||
args := m.Called(key)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *AsyncMockRedisRepo) StoreVerificationCode(phone, code string) error { return nil }
|
||||
func (m *AsyncMockRedisRepo) GetVerificationCode(phone string) (string, error) { return "", nil }
|
||||
func (m *AsyncMockRedisRepo) DeleteVerificationCode(phone string) error { return nil }
|
||||
|
||||
type AsyncMockTenantService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, emailDomain)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil }
|
||||
func (m *AsyncMockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
|
||||
func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
type AsyncMockKetoService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AsyncMockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AsyncMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockKetoService) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestSignup_AsyncDB_Isolation(t *testing.T) {
|
||||
mockIdp := new(AsyncMockIdpProvider)
|
||||
mockUserRepo := new(AsyncMockUserRepo)
|
||||
mockRedis := new(AsyncMockRedisRepo)
|
||||
mockTenant := new(AsyncMockTenantService)
|
||||
mockKeto := new(AsyncMockKetoService)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
UserRepo: mockUserRepo,
|
||||
RedisService: mockRedis,
|
||||
TenantService: mockTenant,
|
||||
KetoService: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/signup", h.Signup)
|
||||
|
||||
t.Run("SoT_DB_Failure_Ignored_And_Async", func(t *testing.T) {
|
||||
email := "test@example.com"
|
||||
phone := "010-1234-5678"
|
||||
emailKey := "signup:email:" + email
|
||||
phoneKey := "signup:phone:" + "01012345678"
|
||||
|
||||
// Redis Mocks
|
||||
mockRedis.On("Get", emailKey).Return(`{"verified": true, "expires_at": 9999999999}`, nil)
|
||||
mockRedis.On("Get", phoneKey).Return(`{"verified": true, "expires_at": 9999999999}`, nil)
|
||||
mockRedis.On("Delete", emailKey).Return(nil)
|
||||
mockRedis.On("Delete", phoneKey).Return(nil)
|
||||
|
||||
// Tenant Mocks
|
||||
personalTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-test", Type: domain.TenantTypePersonal, Status: domain.TenantStatusActive}
|
||||
mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, nil)
|
||||
mockTenant.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
"Personal - test@example.com",
|
||||
mock.MatchedBy(func(slug string) bool { return strings.HasPrefix(slug, "personal-") }),
|
||||
domain.TenantTypePersonal,
|
||||
"Automatically provisioned personal tenant",
|
||||
[]string(nil),
|
||||
(*string)(nil),
|
||||
"",
|
||||
).Return(personalTenant, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "personal-t1").Return(personalTenant, nil)
|
||||
|
||||
// Kratos Mocks (Success)
|
||||
mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil)
|
||||
|
||||
// UserRepo Mocks (Async & Failure)
|
||||
mockUserRepo.createCalled = make(chan bool, 1)
|
||||
mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
|
||||
return u.Email == email
|
||||
})).Return(errors.New("db connection error"))
|
||||
|
||||
// Keto Mocks (Optional, since it's also async)
|
||||
// We won't block on this either
|
||||
|
||||
body, _ := json.Marshal(domain.SignupRequest{
|
||||
Email: email,
|
||||
Password: "Password123!",
|
||||
Name: "Test User",
|
||||
Phone: phone,
|
||||
TermsAccepted: true,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
start := time.Now()
|
||||
resp, err := app.Test(req, 5000)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Ensure API responded faster than DB latency (50ms)
|
||||
assert.Less(t, int64(elapsed), int64(60*time.Millisecond), "API should return before DB timeout")
|
||||
|
||||
// Wait for async execution
|
||||
select {
|
||||
case <-mockUserRepo.createCalled:
|
||||
// Pass
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("UserRepo.Create was not called asynchronously")
|
||||
}
|
||||
|
||||
mockRedis.AssertExpectations(t)
|
||||
mockIdp.AssertExpectations(t)
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
// Mock Hydra transport for revocation
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
// 1. Kratos whoami
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{"id": "user-123"},
|
||||
}), nil
|
||||
}
|
||||
// 2. Hydra Revoke
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
assert.Equal(t, "user-123", r.URL.Query().Get("subject"))
|
||||
assert.Equal(t, "app-1", r.URL.Query().Get("client"))
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
consentRepo := &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "app-1",
|
||||
Subject: "user-123",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil)
|
||||
req.Header.Set("Cookie", "valid")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, 1, len(auditRepo.logs))
|
||||
assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType)
|
||||
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
|
||||
assert.Equal(t, "success", auditRepo.logs[0].Status)
|
||||
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "app-1", auditDetails["client_id"])
|
||||
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||
assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType)
|
||||
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||
assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID)
|
||||
remaining, err := consentRepo.Find(req.Context(), "app-1", "user-123")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, remaining)
|
||||
}
|
||||
|
||||
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
|
||||
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
|
||||
|
||||
var receivedBody string
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{"id": "user-123"},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "app-1",
|
||||
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Host == "rp.example.com" && r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(raw)
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
backchannelLogout, err := service.NewBackchannelLogoutService()
|
||||
assert.NoError(t, err)
|
||||
backchannelLogout.HTTPClient = client
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
BackchannelLogout: backchannelLogout,
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil)
|
||||
req.Header.Set("Cookie", "valid")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.True(t, strings.Contains(receivedBody, "logout_token="))
|
||||
|
||||
values, err := url.ParseQuery(receivedBody)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, values.Get("logout_token"))
|
||||
|
||||
assert.Len(t, auditRepo.logs, 2)
|
||||
assert.Equal(t, "backchannel_logout.sent", auditRepo.logs[1].EventType)
|
||||
}
|
||||
|
||||
func TestListRpHistory_Aggregation(t *testing.T) {
|
||||
now := time.Now()
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "consent.revoked", // Newest
|
||||
Timestamp: now,
|
||||
Details: `{"client_id":"app-1"}`,
|
||||
},
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "consent.granted", // Oldest
|
||||
Timestamp: now.Add(-1 * time.Hour),
|
||||
Details: `{"client_id":"app-1", "client_name":"App One"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/rp/history", h.ListRpHistory)
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{"id": "user-123"},
|
||||
}), nil
|
||||
})
|
||||
http.DefaultClient = &http.Client{Transport: transport}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/history", nil)
|
||||
req.Header.Set("Cookie", "valid")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res struct {
|
||||
Items []struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Equal(t, 1, len(res.Items))
|
||||
assert.Equal(t, "app-1", res.Items[0].ClientID)
|
||||
// Newest event (revoked) should win
|
||||
assert.Equal(t, "revoked", res.Items[0].Status)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
type MockKratosAdminServiceForConsent struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]any) (*service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type MockTenantServiceForConsent struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ApproveTenant(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) SetKetoService(keto service.KetoService) {}
|
||||
|
||||
func (m *MockTenantServiceForConsent) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Test Helpers ---
|
||||
|
||||
func newConsentTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
return app
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestGetConsentRequest_Normal(t *testing.T) {
|
||||
// Mock Hydra transport
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-123",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"skip": false,
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"client_name": "Test App",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
app := newConsentTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-123", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
assert.Equal(t, "challenge-123", body["challenge"])
|
||||
assert.Equal(t, false, body["skip"])
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-tenant-scope",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"skip": false,
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"client_name": "Test App",
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-allow"},
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
mockTenantSvc := &MockTenantServiceForConsent{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
// Mock profile resolution to allow tenant access
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "tenant-allow").Return(&domain.Tenant{
|
||||
ID: "tenant-allow",
|
||||
Slug: "tenant-allow",
|
||||
Name: "Allowed Tenant",
|
||||
}, nil)
|
||||
|
||||
// Mock hydration calls
|
||||
mockTenantSvc.On("ListJoinedTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{
|
||||
{ID: "tenant-allow", Slug: "tenant-allow", Name: "Allowed Tenant"},
|
||||
}, nil)
|
||||
mockTenantSvc.On("ListManageableTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{}, nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
TenantService: mockTenantSvc,
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
}
|
||||
app := newConsentTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-tenant-scope", nil)
|
||||
req.Header.Set("X-Mock-Role", "user")
|
||||
req.Header.Set("X-Tenant-ID", "tenant-allow")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"])
|
||||
scopeDetails := body["scope_details"].(map[string]any)
|
||||
tenantDetail := scopeDetails["tenant"].(map[string]any)
|
||||
assert.Equal(t, true, tenantDetail["mandatory"])
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
// Hydra: Get Consent Request
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-skip",
|
||||
"requested_scope": []string{"openid"},
|
||||
"skip": true,
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
// Kratos: Get Identity
|
||||
if r.URL.Path == "/admin/identities/user-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
// Hydra: Accept Consent Request
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
consentRepo := &mockConsentRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := newConsentTestApp(h)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil)
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.Equal(t, "http://rp/cb", body["redirectTo"])
|
||||
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
|
||||
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
|
||||
assert.Equal(t, "challenge-skip", rpUsageSink.events[0].CorrelationID)
|
||||
assert.Equal(t, true, rpUsageSink.events[0].Payload["auto_accepted"])
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-accept",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"client_name": "Test App",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/admin/identities/user-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
consentRepo := &mockConsentRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := newConsentTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-accept",
|
||||
"grant_scope": []string{"openid"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.Equal(t, 1, len(auditRepo.logs))
|
||||
assert.Equal(t, "consent.granted", auditRepo.logs[0].EventType)
|
||||
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
|
||||
assert.Equal(t, "success", auditRepo.logs[0].Status)
|
||||
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "client-app", auditDetails["client_id"])
|
||||
assert.Equal(t, "Test App", auditDetails["client_name"])
|
||||
assert.Equal(t, []any{"openid"}, auditDetails["scopes"])
|
||||
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
|
||||
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
|
||||
assert.Equal(t, "Test App", rpUsageSink.events[0].ClientName)
|
||||
assert.Equal(t, []string{"openid"}, []string(rpUsageSink.events[0].Scopes))
|
||||
assert.Equal(t, "hydra_consent", rpUsageSink.events[0].Source)
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "dev")
|
||||
|
||||
var capturedGrantScopes []string
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-tenant-accept",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-abc",
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-abc"},
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/admin/identities/user-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
||||
var payload map[string]any
|
||||
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||
for _, scope := range payload["grant_scope"].([]any) {
|
||||
capturedGrantScopes = append(capturedGrantScopes, scope.(string))
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
}
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := newConsentTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-tenant-accept",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Mock-Role", "user")
|
||||
req.Header.Set("X-Tenant-ID", "tenant-abc")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||
traits := map[string]any{
|
||||
"email": "user@baron.com",
|
||||
"name": "홍길동",
|
||||
"tenant_id": "primary-tenant-999", // Added primary tenant
|
||||
"tenant-1": map[string]any{
|
||||
"department": "개발팀",
|
||||
"grade": "선임",
|
||||
},
|
||||
"tenant-2": map[string]any{
|
||||
"department": "재무팀",
|
||||
"grade": "팀장",
|
||||
},
|
||||
}
|
||||
scopes := []string{"openid", "profile"}
|
||||
|
||||
t.Run("No tenantID", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "primary-tenant-999", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-2")
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") // Should contain primary
|
||||
})
|
||||
|
||||
t.Run("With tenant-1", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "tenant-1", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-2")
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
|
||||
})
|
||||
|
||||
t.Run("With tenant-2", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "tenant-2", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
|
||||
})
|
||||
|
||||
t.Run("With non-existent tenant", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "tenant-3", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
|
||||
})
|
||||
|
||||
t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1")
|
||||
assert.Equal(t, "tenant-1", claims["tenant_id"])
|
||||
assert.Equal(t, "개발팀", claims["department"])
|
||||
assert.Equal(t, "선임", claims["grade"])
|
||||
assert.NotNil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-2")
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRepresentativeTenantIDFromTraits(t *testing.T) {
|
||||
t.Run("explicit tenant_id wins", func(t *testing.T) {
|
||||
traits := map[string]any{
|
||||
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", "isPrimary": true},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", representativeTenantIDFromTraits(traits))
|
||||
})
|
||||
|
||||
t.Run("primary appointment wins when tenant_id is absent", func(t *testing.T) {
|
||||
traits := map[string]any{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"},
|
||||
map[string]any{"tenantId": "01970f0c-8c44-7069-9f20-7d28c0b8e630", "representative": true},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "01970f0c-8c44-7069-9f20-7d28c0b8e630", representativeTenantIDFromTraits(traits))
|
||||
})
|
||||
|
||||
t.Run("first appointment is fallback", func(t *testing.T) {
|
||||
traits := map[string]any{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"},
|
||||
map[string]any{"tenantId": "01970f0c-8c44-7069-9f20-7d28c0b8e630"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", representativeTenantIDFromTraits(traits))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
// Hydra: Get Consent Request
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-dynamic",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-abc",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
// Kratos: Get Identity
|
||||
if r.URL.Path == "/admin/identities/user-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant-abc": map[string]any{
|
||||
"department": "Innovation",
|
||||
"position": "Architect",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
// Hydra: Accept Consent Request
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
||||
// Capture the claims sent to Hydra
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant-abc": map[string]any{
|
||||
"department": "Innovation",
|
||||
"position": "Architect",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-dynamic",
|
||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Verify captured claims
|
||||
assert.NotNil(t, capturedClaims)
|
||||
assert.Equal(t, "user@test.com", capturedClaims["email"])
|
||||
assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"])
|
||||
assert.Equal(t, "Innovation", capturedClaims["department"])
|
||||
assert.Equal(t, "Architect", capturedClaims["position"])
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
||||
rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-representative-tenant",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"subject": "user-representative",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": rpContextTenantID,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{
|
||||
ID: "user-representative",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": representativeTenantID, "isPrimary": true},
|
||||
map[string]any{"tenantId": rpContextTenantID},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-representative-tenant",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
assert.Equal(t, representativeTenantID, capturedClaims["tenant_id"])
|
||||
assert.Contains(t, capturedClaims["joined_tenants"], representativeTenantID)
|
||||
assert.Contains(t, capturedClaims["joined_tenants"], rpContextTenantID)
|
||||
assert.Nil(t, capturedClaims["tenants"])
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
||||
secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
||||
companyID := "01970f08-91da-7286-bd19-882fb98d1f2c"
|
||||
rootID := "01970f07-4f01-7d9a-a71e-b53ad508f345"
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-hanmac-tenant-claim",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"subject": "user-hanmac",
|
||||
"client": map[string]any{
|
||||
"client_id": "hanmac-rp",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": deptID,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{
|
||||
ID: "user-hanmac",
|
||||
Traits: map[string]any{
|
||||
"email": "hanmac-user@example.com",
|
||||
"name": "한맥 사용자",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": deptID,
|
||||
"isPrimary": true,
|
||||
"isOwner": true,
|
||||
"grade": "책임",
|
||||
"jobTitle": "기술기획",
|
||||
"position": "팀장",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondDeptID,
|
||||
"isPrimary": false,
|
||||
"isOwner": false,
|
||||
"grade": "선임",
|
||||
"jobTitle": "품질관리",
|
||||
"position": "파트원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockTenantSvc := new(MockTenantService)
|
||||
mockTenantSvc.On("ListJoinedTenants", mock.Anything, "user-hanmac").Return([]domain.Tenant{}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, deptID).Return(&domain.Tenant{
|
||||
ID: deptID,
|
||||
Slug: "tech-planning",
|
||||
Name: "기술기획팀",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &companyID,
|
||||
}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, secondDeptID).Return(&domain.Tenant{
|
||||
ID: secondDeptID,
|
||||
Slug: "quality",
|
||||
Name: "품질관리팀",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &companyID,
|
||||
}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{
|
||||
ID: companyID,
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, rootID).Return(&domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: "hanmac-family",
|
||||
Name: "한맥가족",
|
||||
Type: domain.TenantTypeCompanyGroup,
|
||||
}, nil)
|
||||
h.TenantService = mockTenantSvc
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-hanmac-tenant-claim",
|
||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
assert.Equal(t, []any{deptID}, capturedClaims["lead_tenants"])
|
||||
assert.ElementsMatch(t, []any{deptID, secondDeptID}, capturedClaims["joined_tenants"])
|
||||
tenants := capturedClaims["tenants"].(map[string]any)
|
||||
dept := tenants[deptID].(map[string]any)
|
||||
assert.Equal(t, true, dept["lead"])
|
||||
assert.Equal(t, true, dept["representative"])
|
||||
assert.Equal(t, "책임", dept["grade"])
|
||||
assert.Equal(t, "기술기획", dept["jobTitle"])
|
||||
assert.Equal(t, "팀장", dept["position"])
|
||||
assert.Equal(t, companyID, dept["parentTenantId"])
|
||||
assert.NotContains(t, dept, "parentTenant")
|
||||
|
||||
ancestors := dept["ancestors"].([]any)
|
||||
assert.Len(t, ancestors, 2)
|
||||
companyAncestor := ancestors[0].(map[string]any)
|
||||
assert.Equal(t, companyID, companyAncestor["id"])
|
||||
assert.Equal(t, "hanmac", companyAncestor["slug"])
|
||||
assert.Equal(t, rootID, companyAncestor["parentTenantId"])
|
||||
assert.NotContains(t, companyAncestor, "parentTenant")
|
||||
rootAncestor := ancestors[1].(map[string]any)
|
||||
assert.Equal(t, rootID, rootAncestor["id"])
|
||||
assert.Equal(t, "hanmac-family", rootAncestor["slug"])
|
||||
assert.Contains(t, rootAncestor, "parentTenantId")
|
||||
assert.Nil(t, rootAncestor["parentTenantId"])
|
||||
assert.NotContains(t, rootAncestor, "parentTenant")
|
||||
|
||||
secondDept := tenants[secondDeptID].(map[string]any)
|
||||
assert.Equal(t, false, secondDept["lead"])
|
||||
assert.Equal(t, false, secondDept["representative"])
|
||||
assert.Equal(t, "선임", secondDept["grade"])
|
||||
assert.Equal(t, "품질관리", secondDept["jobTitle"])
|
||||
assert.Equal(t, "파트원", secondDept["position"])
|
||||
assert.Equal(t, companyID, secondDept["parentTenantId"])
|
||||
}
|
||||
|
||||
func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *testing.T) {
|
||||
deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
||||
secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
||||
companyID := "01970f08-91da-7286-bd19-882fb98d1f2c"
|
||||
rootID := "01970f07-4f01-7d9a-a71e-b53ad508f345"
|
||||
|
||||
mockTenantSvc := new(MockTenantService)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, deptID).Return(&domain.Tenant{
|
||||
ID: deptID,
|
||||
Slug: "tech-planning",
|
||||
Name: "기술기획팀",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &companyID,
|
||||
}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, secondDeptID).Return(&domain.Tenant{
|
||||
ID: secondDeptID,
|
||||
Slug: "quality",
|
||||
Name: "품질관리팀",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &companyID,
|
||||
}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{
|
||||
ID: companyID,
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, rootID).Return(&domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: "hanmac-family",
|
||||
Name: "한맥가족",
|
||||
Type: domain.TenantTypeCompanyGroup,
|
||||
}, nil)
|
||||
|
||||
h := &AuthHandler{TenantService: mockTenantSvc}
|
||||
claims := map[string]any{"tenant_id": deptID}
|
||||
traits := map[string]any{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": deptID,
|
||||
"isPrimary": true,
|
||||
"isOwner": true,
|
||||
"grade": "책임",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondDeptID,
|
||||
"grade": "선임",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claims = h.withHanmacFamilyTenantClaims(context.Background(), claims, traits, []string{"openid", "profile"})
|
||||
|
||||
assert.Equal(t, deptID, claims["tenant_id"])
|
||||
assert.ElementsMatch(t, []string{deptID, secondDeptID}, claims["joined_tenants"])
|
||||
assert.NotContains(t, claims, "tenants")
|
||||
assert.NotContains(t, claims, "lead_tenants")
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-rp-profile",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
"metadata": map[string]any{
|
||||
"customUserSchema": []map[string]any{
|
||||
{
|
||||
"key": "approvalLevel",
|
||||
"label": "승인 등급",
|
||||
"type": "text",
|
||||
"claimEnabled": true,
|
||||
},
|
||||
{
|
||||
"key": "internalMemo",
|
||||
"label": "내부 메모",
|
||||
"type": "text",
|
||||
"claimEnabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
},
|
||||
}, nil)
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Get", mock.Anything, "client-app", "user-123").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-app",
|
||||
UserID: "user-123",
|
||||
Metadata: domain.JSONMap{
|
||||
"approvalLevel": "A",
|
||||
"internalMemo": "관리자 전용",
|
||||
},
|
||||
}, nil).Once()
|
||||
h.RPUserMetadataRepo = repo
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-rp-profile",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
rpProfiles, ok := capturedClaims["rp_profiles"].([]any)
|
||||
assert.True(t, ok)
|
||||
assert.Len(t, rpProfiles, 1)
|
||||
profile := rpProfiles[0].(map[string]any)
|
||||
assert.Equal(t, "client-app", profile["client_id"])
|
||||
fields := profile["fields"].(map[string]any)
|
||||
assert.Equal(t, "A", fields["approvalLevel"])
|
||||
assert.NotContains(t, fields, "internalMemo")
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
// Hydra: Get Consent Request
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-skip-dynamic",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"skip": true,
|
||||
"subject": "user-456",
|
||||
"client": map[string]any{
|
||||
"client_id": "skip-app",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-xyz",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
// Kratos: Get Identity
|
||||
if r.URL.Path == "/admin/identities/user-456" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "user-456",
|
||||
"traits": map[string]any{
|
||||
"email": "skip@test.com",
|
||||
"tenant-xyz": map[string]any{
|
||||
"department": "Security",
|
||||
"position": "Officer",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
// Hydra: Accept Consent Request
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
||||
// Capture the claims sent to Hydra
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{
|
||||
ID: "user-456",
|
||||
Traits: map[string]any{
|
||||
"email": "skip@test.com",
|
||||
"tenant-xyz": map[string]any{
|
||||
"department": "Security",
|
||||
"position": "Officer",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Verify captured claims
|
||||
assert.NotNil(t, capturedClaims)
|
||||
assert.Equal(t, "skip@test.com", capturedClaims["email"])
|
||||
assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"])
|
||||
assert.Equal(t, "Security", capturedClaims["department"])
|
||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
||||
}
|
||||
|
||||
func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"global_custom_claims": map[string]any{
|
||||
"contract_date": "2026-06-09",
|
||||
"approved_at": "2026-06-09T09:30:00+09:00",
|
||||
"email": "override@test.com",
|
||||
"rp_claims": "reserved",
|
||||
},
|
||||
"global_custom_claim_permissions": map[string]any{
|
||||
"contract_date": map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
}, []string{"openid", "profile", "email"}, "")
|
||||
|
||||
assert.Equal(t, "2026-06-09", claims["contract_date"])
|
||||
assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"])
|
||||
assert.Equal(t, "user@test.com", claims["email"])
|
||||
assert.NotEqual(t, "reserved", claims["rp_claims"])
|
||||
assert.NotContains(t, claims, "global_custom_claim_permissions")
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-configured-claims",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-789",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-configured-claims",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-claims",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"key": "locale",
|
||||
"value": "ko-KR",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"key": "email",
|
||||
"value": "should-not-override@example.com",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "tier",
|
||||
"value": "2",
|
||||
"valueType": "number",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "features",
|
||||
"value": "[\"sso\",\"claims\"]",
|
||||
"valueType": "array",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{
|
||||
ID: "user-789",
|
||||
Traits: map[string]any{
|
||||
"email": "real-user@example.com",
|
||||
"name": "Configured User",
|
||||
"tenant-claims": map[string]any{
|
||||
"department": "Platform",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-configured-claims",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
assert.Equal(t, "real-user@example.com", capturedClaims["email"])
|
||||
assert.Equal(t, "ko-KR", capturedClaims["locale"])
|
||||
assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"])
|
||||
|
||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, float64(2), rpClaims["tier"])
|
||||
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// Mock services
|
||||
type mockEmailService struct {
|
||||
lastTo string
|
||||
lastSubject string
|
||||
lastBody string
|
||||
}
|
||||
|
||||
func (m *mockEmailService) SendEmail(to, subject, body string) error {
|
||||
m.lastTo = to
|
||||
m.lastSubject = subject
|
||||
m.lastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockSmsService struct {
|
||||
lastTo string
|
||||
lastContent string
|
||||
}
|
||||
|
||||
func (m *mockSmsService) SendSms(to, content string) error {
|
||||
m.lastTo = to
|
||||
m.lastContent = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/headless/link/init", h.HeadlessLinkInit)
|
||||
app.Post("/api/v1/auth/headless/link/poll", h.HeadlessLinkPoll)
|
||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||
return app
|
||||
}
|
||||
|
||||
func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" {
|
||||
http.Error(w, "missing session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "session-123",
|
||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
origDefaultClient := http.DefaultClient
|
||||
http.DefaultClient = server.Client()
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient = origDefaultClient
|
||||
})
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
// Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
initiateLinkErr: domain.ErrNotSupported,
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
EmailService: &mockEmailService{},
|
||||
SmsService: &mockSmsService{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink)
|
||||
app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink)
|
||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
// 1. Init Enchanted Link (Email)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "user@example.com",
|
||||
"method": "email",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var initResp map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
pendingRef := initResp["pendingRef"].(string)
|
||||
assert.NotEmpty(t, pendingRef)
|
||||
|
||||
// Find the token key "enchanted_token:..." in mock redis
|
||||
var token string
|
||||
for k := range redis.data {
|
||||
if len(k) > 16 && k[:16] == "enchanted_token:" {
|
||||
token = k[16:]
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// 2. Verify Magic Link
|
||||
verifyBody, _ := json.Marshal(map[string]any{
|
||||
"token": token,
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// 3. Poll (Success)
|
||||
pollBody, _ := json.Marshal(map[string]string{"pendingRef": pendingRef})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(pollBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var pollResp map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||
assert.Equal(t, "ok", pollResp["status"])
|
||||
assert.Equal(t, "valid-jwt", pollResp["sessionJwt"])
|
||||
}
|
||||
|
||||
func TestEnchantedLinkFlow_Sms_Success(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
initiateLinkErr: domain.ErrNotSupported,
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
SmsService: &mockSmsService{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink)
|
||||
|
||||
// 1. Init Enchanted Link (SMS)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "010-1234-5678",
|
||||
"method": "sms",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var initResp map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
assert.NotEmpty(t, initResp["userCode"])
|
||||
}
|
||||
|
||||
func TestVerifyMagicLink_VerifyOnlyWithoutSharedBrowserSessionApprovesOnly(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"token": "token-123",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Empty(t, resp.Cookies())
|
||||
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "approved", got["status"])
|
||||
assert.Nil(t, got["sessionJwt"])
|
||||
assert.Nil(t, got["token"])
|
||||
}
|
||||
|
||||
func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"token": "token-123",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Empty(t, resp.Cookies())
|
||||
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "approved", got["status"])
|
||||
assert.Nil(t, got["sessionJwt"])
|
||||
assert.Nil(t, got["token"])
|
||||
}
|
||||
|
||||
func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"token": "token-123",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Empty(t, resp.Cookies())
|
||||
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "approved", got["status"])
|
||||
assert.Nil(t, got["sessionJwt"])
|
||||
assert.Nil(t, got["token"])
|
||||
assert.Contains(t, redis.data[prefixSession+"pending-123"], "approved")
|
||||
}
|
||||
|
||||
func TestResolveUserfrontURL_DevLocalhostUsesConfiguredPort(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "dev")
|
||||
t.Setenv("USERFRONT_URL", "http://localhost:5000")
|
||||
|
||||
h := &AuthHandler{}
|
||||
app := fiber.New()
|
||||
app.Get("/probe", func(c *fiber.Ctx) error {
|
||||
return c.SendString(h.resolveUserfrontURL(c))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://localhost/probe", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, "http://localhost:5000", string(body))
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixLoginCode + "user@example.com": "flow-123",
|
||||
prefixLoginCodePending + "user@example.com": "pending-123",
|
||||
prefixLoginCodeValue + "pending-123": "569765",
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"loginId": "user@example.com",
|
||||
"code": "569765",
|
||||
"pendingRef": "pending-123",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Empty(t, resp.Cookies())
|
||||
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "approved", got["status"])
|
||||
assert.Nil(t, got["sessionJwt"])
|
||||
assert.Nil(t, got["token"])
|
||||
assert.Contains(t, redis.data[prefixSession+"pending-123"], "approved")
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixLoginCode + "su-@samaneng.com": "flow-123",
|
||||
prefixLoginCodePending + "su-@samaneng.com": "pending-123",
|
||||
prefixLoginCodeSmsLookup + "+821041585840": "su-@samaneng.com",
|
||||
prefixLoginCodeSmsTarget + "su-@samaneng.com": "+821041585840",
|
||||
prefixLoginCodeValue + "pending-123": "569765",
|
||||
}}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"loginId": "01041585840",
|
||||
"code": "569765",
|
||||
"pendingRef": "pending-123",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "approved", got["status"])
|
||||
assert.Equal(t, "pending-123", got["pendingRef"])
|
||||
}
|
||||
|
||||
func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"pendingRef": "missing-ref",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "expired_token", got["error"])
|
||||
assert.Equal(t, "expired_token", got["code"])
|
||||
}
|
||||
|
||||
func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{
|
||||
issueSession: &domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"},
|
||||
Subject: "kratos-user-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pendingRef": "pending-123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "ok", got["status"])
|
||||
assert.Equal(t, "valid-jwt", got["sessionJwt"])
|
||||
}
|
||||
|
||||
func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{
|
||||
issueSession: &domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"},
|
||||
Subject: "kratos-user-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pendingRef": "pending-123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "session_subject_conflict", got["code"])
|
||||
assert.NotContains(t, redis.data[prefixSession+"pending-123"], "valid-jwt")
|
||||
}
|
||||
|
||||
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
initiateLinkErr: domain.ErrNotSupported,
|
||||
}
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet {
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
SmsService: &mockSmsService{},
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessLinkTestApp(h)
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"loginId": "010-1234-5678",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.NotEmpty(t, got["pendingRef"])
|
||||
_, hasUserCode := got["userCode"]
|
||||
assert.False(t, hasUserCode)
|
||||
}
|
||||
|
||||
func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
initiateLinkErr: domain.ErrNotSupported,
|
||||
}
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
ClientName: "local-demo-rp",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-identity-id", nil)
|
||||
auditRepo := &mockAuditRepo{}
|
||||
headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" {
|
||||
return httpResponse(r, http.StatusOK, string(jwksBody)), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
SmsService: &mockSmsService{},
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessLinkTestApp(h)
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
initBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"loginId": "010-1234-5678",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(initBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var initResp map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
pendingRef := initResp["pendingRef"].(string)
|
||||
assert.NotEmpty(t, pendingRef)
|
||||
|
||||
var token string
|
||||
for k := range redis.data {
|
||||
if len(k) > 16 && k[:16] == "enchanted_token:" {
|
||||
token = k[16:]
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
verifyBody, _ := json.Marshal(map[string]any{
|
||||
"token": token,
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
pollBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
|
||||
"pendingRef": pendingRef,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var pollResp map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
|
||||
assert.Equal(t, "ok", pollResp["status"])
|
||||
assert.Nil(t, pollResp["sessionJwt"])
|
||||
assert.Nil(t, pollResp["token"])
|
||||
assert.Empty(t, resp.Cookies())
|
||||
if assert.Len(t, auditRepo.logs, 1) {
|
||||
assert.Contains(t, auditRepo.logs[0].EventType, "/api/v1/auth/")
|
||||
details, err := parseAuditDetails(auditRepo.logs[0].Details)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse audit details: %v", err)
|
||||
}
|
||||
assert.Equal(t, "headless-login-client", details["client_id"])
|
||||
assert.Equal(t, "local-demo-rp", details["client_name"])
|
||||
assert.Equal(t, "challenge-123", details["login_challenge"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
acceptCalled := false
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
ClientName: "local-demo-rp",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
acceptCalled = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-target-b", nil)
|
||||
auditRepo := &mockAuditRepo{}
|
||||
headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" {
|
||||
return httpResponse(r, http.StatusOK, string(jwksBody)), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{
|
||||
userExists: true,
|
||||
initiateLinkErr: domain.ErrNotSupported,
|
||||
},
|
||||
SmsService: &mockSmsService{},
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessLinkTestApp(h)
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
initBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"loginId": "010-1234-5678",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(initBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var initResp map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
pendingRef := initResp["pendingRef"].(string)
|
||||
assert.NotEmpty(t, pendingRef)
|
||||
|
||||
var token string
|
||||
for k := range redis.data {
|
||||
if len(k) > 16 && k[:16] == "enchanted_token:" {
|
||||
token = k[16:]
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
verifyBody, _ := json.Marshal(map[string]any{
|
||||
"token": token,
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=userfront-a-session")
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
pollBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
|
||||
"pendingRef": pendingRef,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
assert.False(t, acceptCalled)
|
||||
assert.Empty(t, resp.Cookies())
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "oidc_subject_conflict", got["code"])
|
||||
assert.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
|
||||
assert.Equal(t, "kratos-userfront-a", got["currentSubject"])
|
||||
assert.Equal(t, "kratos-target-b", got["targetSubject"])
|
||||
assert.Empty(t, auditRepo.logs)
|
||||
}
|
||||
|
||||
func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
acceptCalled := false
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
acceptCalled = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-target-b", nil)
|
||||
headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" {
|
||||
return httpResponse(r, http.StatusOK, string(jwksBody)), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{
|
||||
userExists: true,
|
||||
initiateLinkErr: domain.ErrNotSupported,
|
||||
},
|
||||
SmsService: &mockSmsService{},
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessLinkTestApp(h)
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
initBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"loginId": "010-1234-5678",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(initBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var initResp map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
pendingRef := initResp["pendingRef"].(string)
|
||||
assert.NotEmpty(t, pendingRef)
|
||||
|
||||
var token string
|
||||
for k := range redis.data {
|
||||
if len(k) > 16 && k[:16] == "enchanted_token:" {
|
||||
token = k[16:]
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
verifyBody, _ := json.Marshal(map[string]any{
|
||||
"token": token,
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
|
||||
pollBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
|
||||
"pendingRef": pendingRef,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=userfront-a-session")
|
||||
resp, _ = app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
assert.False(t, acceptCalled)
|
||||
assert.Empty(t, resp.Cookies())
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "oidc_subject_conflict", got["code"])
|
||||
assert.Equal(t, "kratos-userfront-a", got["currentSubject"])
|
||||
assert.Equal(t, "kratos-target-b", got["targetSubject"])
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
func newLinkedRpTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/rp/linked", h.ListLinkedRps)
|
||||
return app
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||
{
|
||||
"client": map[string]any{
|
||||
"client_id": "devfront",
|
||||
"client_name": "DevFront",
|
||||
"redirect_uris": []string{
|
||||
"https://active.example.com/callback",
|
||||
},
|
||||
},
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
"handled_at": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
"client": map[string]any{
|
||||
"client_id": "orgfront",
|
||||
"client_name": "OrgFront",
|
||||
"metadata": map[string]any{
|
||||
"auto_login_supported": true,
|
||||
"auto_login_url": "http://localhost:5175/login",
|
||||
},
|
||||
"redirect_uris": []string{
|
||||
"http://localhost:5175/auth/callback",
|
||||
},
|
||||
},
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
"handled_at": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/admin/clients/client-audit" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-audit",
|
||||
"client_name": "Audit App",
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/admin/clients/client-consent" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-consent",
|
||||
"client_name": "Consent App",
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() {
|
||||
http.DefaultClient = origDefault
|
||||
}()
|
||||
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "consent.granted",
|
||||
Timestamp: time.Now().Add(-10 * time.Hour),
|
||||
Details: `{"client_id":"client-audit", "scopes":["audit_scope"]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
consentRepo := &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
Subject: "user-123",
|
||||
ClientID: "client-consent",
|
||||
GrantedScopes: []string{"consent_scope"},
|
||||
UpdatedAt: time.Now().Add(-2 * time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
|
||||
t.Setenv("DEVFRONT_URL", "http://localhost:5174")
|
||||
|
||||
app := newLinkedRpTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
InitURL string `json:"init_url"`
|
||||
AutoLoginSupported bool `json:"auto_login_supported"`
|
||||
AutoLoginURL string `json:"auto_login_url"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Equal(t, 4, len(res.Items))
|
||||
|
||||
statusMap := make(map[string]string)
|
||||
for _, item := range res.Items {
|
||||
statusMap[item.ID] = item.Status
|
||||
}
|
||||
|
||||
assert.Equal(t, "active", statusMap["devfront"])
|
||||
assert.Equal(t, "active", statusMap["orgfront"])
|
||||
assert.Equal(t, "inactive", statusMap["client-consent"])
|
||||
assert.Equal(t, "inactive", statusMap["client-audit"])
|
||||
|
||||
var activeInitURL string
|
||||
for _, item := range res.Items {
|
||||
if item.ID == "devfront" {
|
||||
activeInitURL = item.InitURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
parsedInitURL, err := url.Parse(activeInitURL)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "http", parsedInitURL.Scheme)
|
||||
assert.Equal(t, "localhost:5174", parsedInitURL.Host)
|
||||
assert.Equal(t, "/login", parsedInitURL.Path)
|
||||
assert.Equal(t, "1", parsedInitURL.Query().Get("auto"))
|
||||
assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo"))
|
||||
|
||||
var orgfrontItem struct {
|
||||
InitURL string
|
||||
AutoLoginSupported bool
|
||||
AutoLoginURL string
|
||||
}
|
||||
for _, item := range res.Items {
|
||||
if item.ID == "orgfront" {
|
||||
orgfrontItem.InitURL = item.InitURL
|
||||
orgfrontItem.AutoLoginSupported = item.AutoLoginSupported
|
||||
orgfrontItem.AutoLoginURL = item.AutoLoginURL
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, orgfrontItem.AutoLoginSupported)
|
||||
assert.Equal(t, "http://localhost:5175/login?auto=1", orgfrontItem.AutoLoginURL)
|
||||
assert.Equal(t, orgfrontItem.AutoLoginURL, orgfrontItem.InitURL)
|
||||
}
|
||||
|
||||
func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||
{
|
||||
"client": map[string]any{
|
||||
"client_id": "gitea-client",
|
||||
"client_name": "Gitea",
|
||||
"redirect_uris": []string{
|
||||
"https://gitea.example.com/callback",
|
||||
},
|
||||
},
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
"handled_at": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/clients/gitea-client" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "gitea-client",
|
||||
"client_name": "Gitea",
|
||||
"redirect_uris": []string{
|
||||
"https://gitea.example.com/callback",
|
||||
},
|
||||
"metadata": map[string]any{
|
||||
"logo_url": "https://cdn.example.com/gitea.svg",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() {
|
||||
http.DefaultClient = origDefault
|
||||
}()
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
|
||||
|
||||
app := newLinkedRpTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Logo string `json:"logo"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Len(t, res.Items, 1)
|
||||
assert.Equal(t, "gitea-client", res.Items[0].ID)
|
||||
assert.Equal(t, "https://cdn.example.com/gitea.svg", res.Items[0].Logo)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func newVerifyLoginCodeTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
||||
app.Post("/api/v1/auth/login/code/verify-short", h.VerifyLoginShortCode)
|
||||
return app
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_InvalidBody_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "bad_request" {
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_IdpUnavailable_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"loginId": "user@example.com",
|
||||
"code": "AA-111111",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "service_unavailable" {
|
||||
t.Fatalf("expected code=service_unavailable, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_VerifyOnlyInvalidCode_ReturnsExplicitCode(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
redis.data[prefixLoginCode+"user@example.com"] = "flow-1"
|
||||
redis.data[prefixLoginCodePending+"user@example.com"] = "pending-1"
|
||||
redis.data[prefixLoginCodeValue+"pending-1"] = "AB-123"
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"loginId": "user@example.com",
|
||||
"code": "ZZ-999",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "invalid_code" {
|
||||
t.Fatalf("expected code=invalid_code, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginShortCode_MissingShortCode_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
RedisService: &mockRedisRepo{data: make(map[string]string)},
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"shortCode": "",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "bad_request" {
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginShortCode_InvalidOrExpired_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
RedisService: &mockRedisRepo{data: make(map[string]string)},
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"shortCode": "AB-123456",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "invalid_or_expired_code" {
|
||||
t.Fatalf("expected code=invalid_or_expired_code, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginShortCode_VerifyOnlyMissingPendingRef_ReturnsExplicitCode(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
payload, _ := json.Marshal(shortLoginCodePayload{
|
||||
LoginID: "user@example.com",
|
||||
Code: "AB-123",
|
||||
})
|
||||
redis.data[prefixLoginCodeShort+"AB-123456"] = string(payload)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"shortCode": "AB-123456",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "invalid_session_reference" {
|
||||
t.Fatalf("expected code=invalid_session_reference, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func newOidcLoginTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/oidc/login/accept", h.AcceptOidcLoginRequest)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
|
||||
var gotSubject string
|
||||
var gotChallenge string
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
if r.Header.Get("X-Session-Token") != "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "kratos-123",
|
||||
"traits": map[string]any{},
|
||||
},
|
||||
}), nil
|
||||
case "hydra.test":
|
||||
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
gotChallenge = r.URL.Query().Get("login_challenge")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if subject, ok := payload["subject"].(string); ok {
|
||||
gotSubject = subject
|
||||
}
|
||||
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
|
||||
default:
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
})
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() {
|
||||
http.DefaultClient = origDefault
|
||||
}()
|
||||
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
app := newOidcLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=abc123")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["redirectTo"] != "http://rp/cb" {
|
||||
t.Fatalf("unexpected redirectTo: %v", got["redirectTo"])
|
||||
}
|
||||
if gotSubject != "kratos-123" {
|
||||
t.Fatalf("unexpected subject: %v", gotSubject)
|
||||
}
|
||||
if gotChallenge != "challenge-123" {
|
||||
t.Fatalf("unexpected login_challenge: %v", gotChallenge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
|
||||
var gotSubject string
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
if r.Header.Get("X-Session-Token") != "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "kratos-456",
|
||||
"traits": map[string]any{},
|
||||
},
|
||||
}), nil
|
||||
case "hydra.test":
|
||||
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if subject, ok := payload["subject"].(string); ok {
|
||||
gotSubject = subject
|
||||
}
|
||||
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
|
||||
default:
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
})
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() {
|
||||
http.DefaultClient = origDefault
|
||||
}()
|
||||
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
app := newOidcLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"login_challenge": "challenge-456",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
req.Header.Set("Cookie", "ory_kratos_session=def456")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if gotSubject != "kratos-456" {
|
||||
t.Fatalf("unexpected subject: %v", gotSubject)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandleKratosCourierRelay_Email(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
emailSvc := &mockEmailService{}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
EmailService: emailSvc,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay)
|
||||
|
||||
// Simulate Kratos Courier Request for Email
|
||||
reqBody := map[string]any{
|
||||
"recipient": "user@example.com",
|
||||
"template_type": "verification_code",
|
||||
"template_data": map[string]any{
|
||||
"verification_code": "123456",
|
||||
},
|
||||
"subject": "Verify your email",
|
||||
"body": "Your code is 123456",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/kratos/courier", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestVerifySignupCode_Success(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
|
||||
|
||||
// Mock stored code in redis
|
||||
// signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...}
|
||||
state := map[string]any{
|
||||
"code": "654321",
|
||||
"verified": false,
|
||||
"expires_at": 9999999999, // far future
|
||||
}
|
||||
stateJSON, _ := json.Marshal(state)
|
||||
redis.data["signup:email:user@test.com"] = string(stateJSON)
|
||||
|
||||
// Verify Code
|
||||
verifyBody := map[string]string{
|
||||
"type": "email",
|
||||
"target": "user@test.com",
|
||||
"code": "654321",
|
||||
}
|
||||
body, _ := json.Marshal(verifyBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.True(t, res["success"].(bool))
|
||||
|
||||
// Check redis state updated to verified
|
||||
val, _ := redis.Get("signup:email:user@test.com")
|
||||
var updatedState map[string]any
|
||||
json.Unmarshal([]byte(val), &updatedState)
|
||||
assert.True(t, updatedState["verified"].(bool))
|
||||
}
|
||||
|
||||
func TestVerifySignupCode_Invalid(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
|
||||
|
||||
stateJSON, _ := json.Marshal(map[string]any{
|
||||
"code": "111111",
|
||||
"expires_at": 9999999999,
|
||||
})
|
||||
redis.data["signup:email:user@test.com"] = string(stateJSON)
|
||||
|
||||
verifyBody := map[string]string{
|
||||
"type": "email",
|
||||
"target": "user@test.com",
|
||||
"code": "222222", // wrong code
|
||||
}
|
||||
body, _ := json.Marshal(verifyBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type recordingUpdateMeUserRepo struct {
|
||||
MockUserRepoForHandler
|
||||
updated *domain.User
|
||||
loginIDs []domain.UserLoginID
|
||||
}
|
||||
|
||||
func (r *recordingUpdateMeUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||
copied := *user
|
||||
r.updated = &copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||
r.loginIDs = append([]domain.UserLoginID(nil), loginIDs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingUpdateMeKratosAdmin struct {
|
||||
MockKratosAdminService
|
||||
updatedIdentityID string
|
||||
updatedTraits map[string]any
|
||||
updatedState string
|
||||
storedTraits map[string]any
|
||||
}
|
||||
|
||||
func (r *recordingUpdateMeKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||
r.updatedIdentityID = identityID
|
||||
r.updatedTraits = maps.Clone(traits)
|
||||
r.updatedState = state
|
||||
if r.storedTraits != nil {
|
||||
maps.Copy(r.storedTraits, traits)
|
||||
}
|
||||
return &service.KratosIdentity{
|
||||
ID: identityID,
|
||||
Traits: traits,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
token := "token-abc"
|
||||
identityID := "user-1"
|
||||
traits := map[string]any{
|
||||
"email": "qa@example.com",
|
||||
"name": "QA User",
|
||||
"phone_number": "+821012345678",
|
||||
"department": "Old Dept",
|
||||
"affiliationType": "employee",
|
||||
"companyCode": "",
|
||||
"role": domain.RoleUser,
|
||||
}
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet:
|
||||
if r.Header.Get("X-Session-Token") != token {
|
||||
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": traits,
|
||||
},
|
||||
}), nil
|
||||
|
||||
case r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/admin/identities/"+identityID &&
|
||||
r.Method == http.MethodPut:
|
||||
var payload struct {
|
||||
Traits map[string]any `json:"traits"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
||||
}
|
||||
maps.Copy(traits, payload.Traits)
|
||||
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
kratosAdmin := &recordingUpdateMeKratosAdmin{storedTraits: traits}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
KratosAdmin: kratosAdmin,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
app.Put("/api/v1/user/me", h.UpdateMe)
|
||||
|
||||
// 1) 첫 조회로 Old Dept가 캐시에 저장됨
|
||||
getReq1 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
getReq1.Header.Set("Authorization", "Bearer "+token)
|
||||
getResp1, err := app.Test(getReq1, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, getResp1.StatusCode)
|
||||
var profile1 map[string]any
|
||||
require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1))
|
||||
require.Equal(t, "Old Dept", profile1["department"])
|
||||
|
||||
// 2) 소속을 New Dept로 변경
|
||||
updateBody, _ := json.Marshal(map[string]string{
|
||||
"name": "QA User",
|
||||
"phone": "01012345678",
|
||||
"department": "New Dept",
|
||||
})
|
||||
updateReq := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/user/me",
|
||||
bytes.NewReader(updateBody),
|
||||
)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
updateResp, err := app.Test(updateReq, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, "New Dept", traits["department"])
|
||||
require.Equal(t, identityID, kratosAdmin.updatedIdentityID)
|
||||
require.Equal(t, "New Dept", kratosAdmin.updatedTraits["department"])
|
||||
|
||||
// 3) 새로고침 재조회 시 New Dept가 보여야 함(캐시 무효화 회귀 방지)
|
||||
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
getReq2.Header.Set("Authorization", "Bearer "+token)
|
||||
getResp2, err := app.Test(getReq2, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, getResp2.StatusCode)
|
||||
var profile2 map[string]any
|
||||
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
|
||||
require.Equal(t, "New Dept", profile2["department"])
|
||||
}
|
||||
|
||||
func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||
token := "token-sync"
|
||||
identityID := "user-sync"
|
||||
traits := map[string]any{
|
||||
"email": "sync@example.com",
|
||||
"name": "Old Name",
|
||||
"phone_number": "+821012345678",
|
||||
"department": "Old Dept",
|
||||
"affiliationType": "employee",
|
||||
"companyCode": "saman",
|
||||
"tenant_id": "11111111-1111-1111-1111-111111111111",
|
||||
"role": domain.RoleUser,
|
||||
}
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet:
|
||||
if r.Header.Get("X-Session-Token") != token {
|
||||
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": traits,
|
||||
},
|
||||
}), nil
|
||||
|
||||
case r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/admin/identities/"+identityID &&
|
||||
r.Method == http.MethodPut:
|
||||
var payload struct {
|
||||
Traits map[string]any `json:"traits"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
||||
}
|
||||
maps.Copy(traits, payload.Traits)
|
||||
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
"verify_update_phone:" + identityID + ":+821087654321": "verified",
|
||||
}}
|
||||
userRepo := &recordingUpdateMeUserRepo{}
|
||||
kratosAdmin := &recordingUpdateMeKratosAdmin{storedTraits: traits}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
UserRepo: userRepo,
|
||||
KratosAdmin: kratosAdmin,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Put("/api/v1/user/me", h.UpdateMe)
|
||||
|
||||
updateBody, _ := json.Marshal(map[string]any{
|
||||
"name": "New Name",
|
||||
"phone": "01087654321",
|
||||
"department": "New Dept",
|
||||
})
|
||||
updateReq := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/user/me",
|
||||
bytes.NewReader(updateBody),
|
||||
)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
updateResp, err := app.Test(updateReq, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, identityID, kratosAdmin.updatedIdentityID)
|
||||
require.Equal(t, "New Name", kratosAdmin.updatedTraits["name"])
|
||||
require.Equal(t, "+821087654321", kratosAdmin.updatedTraits["phone_number"])
|
||||
|
||||
require.NotNil(t, userRepo.updated)
|
||||
require.Equal(t, identityID, userRepo.updated.ID)
|
||||
require.Equal(t, "sync@example.com", userRepo.updated.Email)
|
||||
require.Equal(t, "New Name", userRepo.updated.Name)
|
||||
require.Equal(t, "+821087654321", userRepo.updated.Phone)
|
||||
require.Equal(t, "New Dept", userRepo.updated.Department)
|
||||
require.Empty(t, userRepo.updated.CompanyCode)
|
||||
require.NotNil(t, userRepo.updated.TenantID)
|
||||
require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Mock Redis ---
|
||||
|
||||
type mockRedisRepo struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *mockRedisRepo) Set(key, value string, ttl time.Duration) error {
|
||||
if m.data == nil {
|
||||
m.data = make(map[string]string)
|
||||
}
|
||||
m.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRedisRepo) Get(key string) (string, error) {
|
||||
// Bypass rate limiting for tests
|
||||
if strings.HasPrefix(key, "poll_meta:") {
|
||||
return "", nil
|
||||
}
|
||||
return m.data[key], nil
|
||||
}
|
||||
|
||||
func (m *mockRedisRepo) Delete(key string) error {
|
||||
delete(m.data, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRedisRepo) StoreVerificationCode(phone, code string) error {
|
||||
return m.Set("sms:"+phone, code, time.Minute)
|
||||
}
|
||||
|
||||
func (m *mockRedisRepo) GetVerificationCode(phone string) (string, error) {
|
||||
return m.Get("sms:" + phone)
|
||||
}
|
||||
|
||||
func (m *mockRedisRepo) DeleteVerificationCode(phone string) error {
|
||||
return m.Delete("sms:" + phone)
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestQRLoginFlow_Success(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/qr/init", h.InitQRLogin)
|
||||
app.Post("/api/v1/auth/qr/poll", h.PollQRLogin)
|
||||
|
||||
// 1. Init QR Login
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/init", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var initResp map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
pendingRef := initResp["pendingRef"].(string)
|
||||
|
||||
// 2. Poll (Pending)
|
||||
body, _ := json.Marshal(map[string]string{"pendingRef": pendingRef})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
|
||||
// Expect authorization_pending (400)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
var pollResp map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||
assert.Equal(t, "authorization_pending", pollResp["error"])
|
||||
assert.Equal(t, "authorization_pending", pollResp["code"])
|
||||
|
||||
// 3. Mock Approval
|
||||
sessionData, _ := json.Marshal(map[string]string{
|
||||
"status": "success",
|
||||
"jwt": "mock-session-jwt",
|
||||
})
|
||||
redis.data["enchanted_session:"+pendingRef] = string(sessionData)
|
||||
|
||||
// 4. Poll (Success)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var successResp map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&successResp)
|
||||
assert.Equal(t, "ok", successResp["status"])
|
||||
assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"])
|
||||
}
|
||||
|
||||
func TestScanQRLogin_Success(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
idp := &mockIdpProvider{userExists: true}
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/qr/approve", h.ScanQRLogin)
|
||||
|
||||
pendingRef := "test-ref"
|
||||
redis.data["enchanted_session:"+pendingRef] = `{"status":"pending"}`
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = &http.Client{Transport: transport}
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"pendingRef": pendingRef,
|
||||
"token": "valid-token",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/approve", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Header.Get("X-Session-Token") == "token-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "user-token",
|
||||
"traits": map[string]any{
|
||||
"email": "token@test.com",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "user-cookie",
|
||||
"traits": map[string]any{
|
||||
"email": "cookie@test.com",
|
||||
"phone": "010-1234-5678",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
|
||||
})
|
||||
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = &http.Client{Transport: transport}
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
// Token case
|
||||
app.Get("/test-token", func(c *fiber.Ctx) error {
|
||||
subjects, err := h.resolveConsentSubjects(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, subjects, "user-token")
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/test-token", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-123")
|
||||
app.Test(req, -1)
|
||||
|
||||
// Cookie case
|
||||
app.Get("/test-cookie", func(c *fiber.Ctx) error {
|
||||
subjects, err := h.resolveConsentSubjects(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, subjects, "user-cookie")
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
req = httptest.NewRequest("GET", "/test-cookie", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=cookie-123")
|
||||
app.Test(req, -1)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) {
|
||||
const (
|
||||
token = "token-session"
|
||||
identityID = "user-session"
|
||||
sessionAuthenticated = "2026-03-23T15:30:00Z"
|
||||
)
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet {
|
||||
require.Equal(t, token, r.Header.Get("X-Session-Token"))
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "kratos-session-1",
|
||||
"authenticated_at": sessionAuthenticated,
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "qa@example.com",
|
||||
"name": "QA User",
|
||||
"department": "Platform",
|
||||
"affiliationType": "GENERAL",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var profile map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
|
||||
}
|
||||
|
||||
func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) {
|
||||
const (
|
||||
cookieHeader = "ory_kratos_session=session-cookie"
|
||||
identityID = "user-cookie"
|
||||
sessionAuthenticated = "2026-03-24T01:20:00Z"
|
||||
)
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet {
|
||||
require.Equal(t, cookieHeader, r.Header.Get("Cookie"))
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "kratos-session-cookie",
|
||||
"authenticated_at": sessionAuthenticated,
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "cookie@example.com",
|
||||
"name": "Cookie User",
|
||||
"department": "Platform",
|
||||
"affiliationType": "GENERAL",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
req.Header.Set("Cookie", cookieHeader)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var profile map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestListMySessions_Success(t *testing.T) {
|
||||
now := time.Date(2026, 4, 2, 1, 2, 3, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{
|
||||
ID: "current-sid",
|
||||
Active: true,
|
||||
AuthenticatedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "other-sid",
|
||||
Active: true,
|
||||
AuthenticatedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: now.Add(22 * time.Hour),
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "login_success",
|
||||
SessionID: "other-sid",
|
||||
Timestamp: now.Add(-30 * time.Minute),
|
||||
IPAddress: "203.0.113.10",
|
||||
UserAgent: "Mozilla/5.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 2) {
|
||||
assert.Equal(t, "current-sid", body.Items[0].SessionID)
|
||||
assert.True(t, body.Items[0].IsCurrent)
|
||||
assert.Equal(t, "other-sid", body.Items[1].SessionID)
|
||||
assert.True(t, body.Items[1].IsActive)
|
||||
assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress)
|
||||
assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent)
|
||||
}
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListMySessions_UsesConsentGrantForAppName(t *testing.T) {
|
||||
now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{
|
||||
ID: "current-sid",
|
||||
Active: true,
|
||||
AuthenticatedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "c7c721ea-session",
|
||||
Active: true,
|
||||
AuthenticatedAt: now.Add(-5 * time.Minute),
|
||||
ExpiresAt: now.Add(23*time.Hour + 55*time.Minute),
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "consent.granted",
|
||||
SessionID: "c7c721ea-session",
|
||||
Timestamp: now,
|
||||
Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
AppName string `json:"app_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 2) {
|
||||
assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID)
|
||||
assert.Equal(t, "DevFront", body.Items[1].AppName)
|
||||
assert.Equal(t, "devfront", body.Items[1].ClientID)
|
||||
}
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) {
|
||||
now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{
|
||||
ID: "current-sid",
|
||||
Active: true,
|
||||
AuthenticatedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "c7c721ea-session",
|
||||
Active: true,
|
||||
AuthenticatedAt: now.Add(-5 * time.Minute),
|
||||
ExpiresAt: now.Add(23*time.Hour + 55*time.Minute),
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "consent.granted",
|
||||
SessionID: "c7c721ea-session",
|
||||
Timestamp: now.Add(-30 * time.Second),
|
||||
IPAddress: "203.0.113.10",
|
||||
Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`,
|
||||
},
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "login_success",
|
||||
SessionID: "c7c721ea-session",
|
||||
Timestamp: now,
|
||||
IPAddress: "10.0.0.12",
|
||||
UserAgent: "Mozilla/5.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
AppName string `json:"app_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 2) {
|
||||
assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID)
|
||||
assert.Equal(t, "DevFront", body.Items[1].AppName)
|
||||
assert.Equal(t, "devfront", body.Items[1].ClientID)
|
||||
assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress)
|
||||
}
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) {
|
||||
now := time.Date(2026, 4, 6, 1, 2, 3, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{
|
||||
ID: "current-sid",
|
||||
Active: true,
|
||||
AuthenticatedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: &mockAuditRepo{},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36")
|
||||
req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ClientID string `json:"client_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 1) {
|
||||
assert.Equal(t, "current-sid", body.Items[0].SessionID)
|
||||
assert.True(t, body.Items[0].IsCurrent)
|
||||
assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress)
|
||||
assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0")
|
||||
assert.Equal(t, "userfront", body.Items[0].ClientID)
|
||||
assert.Equal(t, "UserFront", body.Items[0].AppName)
|
||||
}
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDeleteMySession_Success(t *testing.T) {
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
var hydraRevokeCalls int
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
if r.URL.Query().Get("subject") != "user-123" {
|
||||
t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject"))
|
||||
}
|
||||
if r.URL.Query().Get("client") != "devfront" {
|
||||
t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client"))
|
||||
}
|
||||
hydraRevokeCalls++
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
setDefaultHTTPClientForTest(t, client.Transport)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{ID: "target-sid", Active: true},
|
||||
}, nil).Once()
|
||||
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
||||
ID: "target-sid",
|
||||
Active: true,
|
||||
}, nil).Once()
|
||||
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
|
||||
UserID: "user-123",
|
||||
EventType: "POST /api/v1/auth/oidc/login/accept",
|
||||
SessionID: "target-sid",
|
||||
Details: `{"client_id":"devfront","client_name":"Devfront"}`,
|
||||
})
|
||||
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
req.Header.Set("User-Agent", "session-test-agent")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
if assert.Len(t, auditRepo.logs, 2) {
|
||||
assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType)
|
||||
assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID)
|
||||
assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID)
|
||||
assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid")
|
||||
}
|
||||
assert.Equal(t, 1, hydraRevokeCalls)
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t *testing.T) {
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
var hydraRevokeCalls int
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
hydraRevokeCalls++
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
setDefaultHTTPClientForTest(t, client.Transport)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{ID: "target-sid", Active: true},
|
||||
}, nil).Once()
|
||||
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
||||
ID: "target-sid",
|
||||
Active: true,
|
||||
}, nil).Once()
|
||||
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
req.Header.Set("User-Agent", "session-test-agent")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, 0, hydraRevokeCalls)
|
||||
if assert.Len(t, auditRepo.logs, 1) {
|
||||
assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType)
|
||||
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
|
||||
assert.Contains(t, auditRepo.logs[0].Details, "target-sid")
|
||||
}
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDeleteMySession_SendsBackchannelLogoutTokenWhenClientConfigured(t *testing.T) {
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
|
||||
|
||||
var receivedBody string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/devfront" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "devfront",
|
||||
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
|
||||
}), nil
|
||||
}
|
||||
case "rp.example.com":
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(raw)
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
setDefaultHTTPClientForTest(t, client.Transport)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{ID: "target-sid", Active: true},
|
||||
}, nil).Once()
|
||||
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
||||
ID: "target-sid",
|
||||
Active: true,
|
||||
}, nil).Once()
|
||||
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
|
||||
|
||||
backchannelLogout, err := service.NewBackchannelLogoutService()
|
||||
assert.NoError(t, err)
|
||||
backchannelLogout.HTTPClient = client
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
BackchannelLogout: backchannelLogout,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
|
||||
UserID: "user-123",
|
||||
EventType: "POST /api/v1/auth/oidc/login/accept",
|
||||
SessionID: "target-sid",
|
||||
Details: `{"client_id":"devfront","client_name":"Devfront"}`,
|
||||
})
|
||||
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
req.Header.Set("User-Agent", "session-test-agent")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.True(t, strings.Contains(receivedBody, "logout_token="))
|
||||
|
||||
values, err := url.ParseQuery(receivedBody)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, values.Get("logout_token"))
|
||||
|
||||
foundBackchannelAudit := false
|
||||
for _, log := range auditRepo.logs {
|
||||
if log.EventType == "backchannel_logout.sent" {
|
||||
foundBackchannelAudit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundBackchannelAudit)
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
var hydraRevokeCalls int
|
||||
var revokedClient string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
revokedClient = r.URL.Query().Get("client")
|
||||
hydraRevokeCalls++
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
setDefaultHTTPClientForTest(t, client.Transport)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{ID: "target-sid", Active: true},
|
||||
}, nil).Once()
|
||||
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
||||
ID: "target-sid",
|
||||
Active: true,
|
||||
}, nil).Once()
|
||||
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
|
||||
UserID: "user-123",
|
||||
EventType: "POST /api/v1/auth/password/login",
|
||||
SessionID: "target-sid",
|
||||
Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`,
|
||||
})
|
||||
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
req.Header.Set("User-Agent", "session-test-agent")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, 1, hydraRevokeCalls)
|
||||
assert.Equal(t, "adminfront", revokedClient)
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != "token=opaque-token" {
|
||||
t.Fatalf("unexpected introspect body: %s", string(body))
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"active": true,
|
||||
"sub": "user-123",
|
||||
"client_id": "devfront",
|
||||
"ext": map[string]any{
|
||||
"session_id": "target-sid",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
||||
ID: "target-sid",
|
||||
Active: false,
|
||||
Identity: &service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
|
||||
profile, err := h.getHydraProfile(context.Background(), "opaque-token")
|
||||
assert.Nil(t, profile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "inactive")
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) {
|
||||
now := time.Date(2026, 4, 7, 4, 39, 0, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
h := &AuthHandler{
|
||||
AuditRepo: &mockAuditRepo{},
|
||||
OathkeeperRepo: &mockOathkeeperRepo{
|
||||
logs: []domain.OathkeeperAccessLog{
|
||||
{
|
||||
Timestamp: now,
|
||||
RequestID: "req-1",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/dev/sessions",
|
||||
Status: http.StatusOK,
|
||||
Subject: "user-123",
|
||||
ClientIP: "203.0.113.7",
|
||||
UserAgent: "Mozilla/5.0",
|
||||
Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
AppName string `json:"app_name"`
|
||||
Source string `json:"source"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 1) {
|
||||
assert.Equal(t, "target-sid", body.Items[0].SessionID)
|
||||
assert.Equal(t, "devfront", body.Items[0].ClientID)
|
||||
assert.Equal(t, "devfront", body.Items[0].AppName)
|
||||
assert.Equal(t, "oathkeeper", body.Items[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthTimeline_IncludesHeadlessPasswordLogin(t *testing.T) {
|
||||
now := time.Date(2026, 4, 7, 5, 10, 0, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
h := &AuthHandler{
|
||||
AuditRepo: &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
EventID: "audit-1",
|
||||
Timestamp: now,
|
||||
UserID: "user-123",
|
||||
SessionID: "headless-session-1",
|
||||
EventType: "POST /api/v1/auth/headless/password/login",
|
||||
Status: "success",
|
||||
IPAddress: "203.0.113.20",
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36",
|
||||
Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1","login_id":"user@example.com","login_challenge":"challenge-123"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
AppName string `json:"app_name"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
EventType string `json:"event_type"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 1) {
|
||||
assert.Equal(t, "headless-session-1", body.Items[0].SessionID)
|
||||
assert.Equal(t, "headless-login-client", body.Items[0].ClientID)
|
||||
assert.Equal(t, "Headless Login Portal", body.Items[0].AppName)
|
||||
assert.Equal(t, "비밀번호(Email)", body.Items[0].AuthMethod)
|
||||
assert.Equal(t, "POST /api/v1/auth/headless/password/login", body.Items[0].EventType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMySessions_UsesHeadlessPasswordLoginForClientBinding(t *testing.T) {
|
||||
now := time.Date(2026, 4, 7, 5, 35, 0, 0, time.UTC)
|
||||
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": now.Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{
|
||||
ID: "current-sid",
|
||||
Active: true,
|
||||
AuthenticatedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "headless-session-1",
|
||||
Active: true,
|
||||
AuthenticatedAt: now.Add(-10 * time.Minute),
|
||||
ExpiresAt: now.Add(23*time.Hour + 50*time.Minute),
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
UserID: "user-123",
|
||||
EventType: "POST /api/v1/auth/headless/password/login",
|
||||
SessionID: "headless-session-1",
|
||||
Timestamp: now,
|
||||
IPAddress: "203.0.113.20",
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36",
|
||||
Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
SessionID string `json:"session_id"`
|
||||
AppName string `json:"app_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
} `json:"items"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, body.Items, 2) {
|
||||
assert.Equal(t, "headless-session-1", body.Items[1].SessionID)
|
||||
assert.Equal(t, "Headless Login Portal", body.Items[1].AppName)
|
||||
assert.Equal(t, "headless-login-client", body.Items[1].ClientID)
|
||||
assert.Equal(t, "203.0.113.20", body.Items[1].IPAddress)
|
||||
assert.Equal(t, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", body.Items[1].UserAgent)
|
||||
}
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Local Mocks for Signup Test ---
|
||||
|
||||
type MockRedisForSignup struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockRedisForSignup) Set(key string, value string, ttl time.Duration) error {
|
||||
return m.Called(key, value, ttl).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRedisForSignup) Get(key string) (string, error) {
|
||||
args := m.Called(key)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRedisForSignup) Delete(key string) error {
|
||||
return m.Called(key).Error(0)
|
||||
}
|
||||
func (m *MockRedisForSignup) StoreVerificationCode(phone, code string) error { return nil }
|
||||
func (m *MockRedisForSignup) GetVerificationCode(phone string) (string, error) { return "", nil }
|
||||
func (m *MockRedisForSignup) DeleteVerificationCode(phone string) error { return nil }
|
||||
func (m *MockRedisForSignup) Ping(ctx context.Context) error { return nil }
|
||||
|
||||
type MockIdpForSignup struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockIdpForSignup) Name() string { return "mock-idp" }
|
||||
func (m *MockIdpForSignup) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
return &domain.IDPMetadata{SupportedFields: []string{"email", "name", "phoneNumber", "grade", "department"}}, nil
|
||||
}
|
||||
|
||||
func (m *MockIdpForSignup) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockIdpForSignup) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockIdpForSignup) UserExists(loginID string) (bool, error) { return false, nil }
|
||||
func (m *MockIdpForSignup) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil }
|
||||
func (m *MockIdpForSignup) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdpForSignup) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdpForSignup) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return &domain.PasswordPolicy{MinLength: 12}, nil
|
||||
}
|
||||
func (m *MockIdpForSignup) InitiatePasswordReset(loginID, redirectUrl string) error { return nil }
|
||||
func (m *MockIdpForSignup) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSignup_TenantSlugValidation(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockTenantSvc := new(MockTenantService)
|
||||
mockRedis := new(MockRedisForSignup)
|
||||
mockIdp := new(MockIdpForSignup)
|
||||
|
||||
h := &AuthHandler{
|
||||
TenantService: mockTenantSvc,
|
||||
RedisService: mockRedis,
|
||||
IdpProvider: mockIdp,
|
||||
}
|
||||
|
||||
app.Post("/signup", h.Signup)
|
||||
|
||||
// Prepare mock state (already verified email/phone)
|
||||
verifiedState, _ := json.Marshal(map[string]any{
|
||||
"verified": true,
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil)
|
||||
|
||||
t.Run("Rejects legacy CompanyCode", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@gmail.com",
|
||||
Password: "StrongPass123!",
|
||||
Name: "Test User",
|
||||
Phone: "010-1234-5678",
|
||||
TermsAccepted: true,
|
||||
CompanyCode: "new-slug",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Active Tenant Slug", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@hanmaceng.co.kr",
|
||||
Password: "StrongPass123!",
|
||||
Name: "Test User",
|
||||
Phone: "010-1234-5678",
|
||||
TermsAccepted: true,
|
||||
TenantSlug: "hanmac",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive}
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once()
|
||||
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe()
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once()
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once()
|
||||
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once()
|
||||
mockRedis.On("Delete", mock.Anything).Return(nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// helper to build a Fiber app with the handler route mounted.
|
||||
func newTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
|
||||
return app
|
||||
}
|
||||
|
||||
func newResetFlowTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken)
|
||||
app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
|
||||
return app
|
||||
}
|
||||
|
||||
func newResetInitAppWithErrorCodeEnricher(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||
return app
|
||||
}
|
||||
|
||||
type testRedisRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error {
|
||||
if m.values == nil {
|
||||
m.values = map[string]string{}
|
||||
}
|
||||
m.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) Get(key string) (string, error) {
|
||||
if m.values == nil {
|
||||
return "", nil
|
||||
}
|
||||
return m.values[key], nil
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) Delete(key string) error {
|
||||
if m.values != nil {
|
||||
delete(m.values, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) StoreVerificationCode(phone, code string) error {
|
||||
return m.Set("sms:"+phone, code, time.Minute)
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) {
|
||||
return m.Get("sms:" + phone)
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) DeleteVerificationCode(phone string) error {
|
||||
return m.Delete("sms:" + phone)
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "Password1!",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing loginId, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Login ID and new password are required" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "short", // too short + missing complexity
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for weak password, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "비밀번호는 최소 12자 이상이어야 합니다" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_NilIDPProvider(t *testing.T) {
|
||||
h := &AuthHandler{} // IdpProvider intentionally nil to hit the configuration error branch
|
||||
app := newTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "StrongPass1!",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500 when IDP provider is nil, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Authentication service not configured" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) {
|
||||
const resetToken = "tok-reset-1"
|
||||
const tokenLoginID = "user@example.com"
|
||||
const wrongLoginID = "wrong@example.com"
|
||||
const newPassword = "StrongPass1!"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + resetToken: tokenLoginID,
|
||||
},
|
||||
}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
err: nil,
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": newPassword,
|
||||
})
|
||||
url := fmt.Sprintf(
|
||||
"/api/v1/auth/password/reset/complete?loginId=%s&token=%s",
|
||||
wrongLoginID,
|
||||
resetToken,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if !idp.updateCalled {
|
||||
t.Fatal("expected UpdateUserPassword to be called")
|
||||
}
|
||||
if idp.updatedLoginID != tokenLoginID {
|
||||
t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID)
|
||||
}
|
||||
if idp.updatedPassword != newPassword {
|
||||
t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) {
|
||||
const resetToken = "invalid-token"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{},
|
||||
}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
err: nil,
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "StrongPass1!",
|
||||
})
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode)
|
||||
}
|
||||
if idp.updateCalled {
|
||||
t.Fatal("UpdateUserPassword must not be called when token is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
|
||||
const resetToken = "dup-token"
|
||||
const loginID = "user@example.com"
|
||||
const newPassword = "StrongPass1!"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + resetToken: loginID,
|
||||
},
|
||||
}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
err: nil,
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": newPassword,
|
||||
})
|
||||
url := fmt.Sprintf(
|
||||
"/api/v1/auth/password/reset/complete?token=%s",
|
||||
resetToken,
|
||||
)
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
firstReq.Header.Set("Content-Type", "application/json")
|
||||
firstResp, err := app.Test(firstReq)
|
||||
if err != nil {
|
||||
t.Fatalf("first request failed: %v", err)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
if idp.updateCallCount != 1 {
|
||||
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
|
||||
}
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
secondReq.Header.Set("Content-Type", "application/json")
|
||||
secondResp, err := app.Test(secondReq)
|
||||
if err != nil {
|
||||
t.Fatalf("second request failed: %v", err)
|
||||
}
|
||||
defer secondResp.Body.Close()
|
||||
|
||||
if secondResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
|
||||
}
|
||||
if idp.updateCallCount != 1 {
|
||||
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||
const token = "tok-enc"
|
||||
const loginID = "user+alias@example.com"
|
||||
|
||||
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + token: loginID,
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/verify?token="+token,
|
||||
nil,
|
||||
)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
t.Fatal("missing redirect location")
|
||||
}
|
||||
redirectReq := httptest.NewRequest(http.MethodGet, location, nil)
|
||||
gotLoginID := redirectReq.URL.Query().Get("loginId")
|
||||
if gotLoginID != loginID {
|
||||
t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
|
||||
const token = "tok-ve"
|
||||
const loginID = "user@example.com"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + token: loginID,
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
|
||||
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
|
||||
|
||||
getReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/auth/password/reset/ve?token="+token,
|
||||
nil,
|
||||
)
|
||||
getResp, err := app.Test(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get request failed: %v", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/ve?token="+token,
|
||||
nil,
|
||||
)
|
||||
postResp, err := app.Test(postReq)
|
||||
if err != nil {
|
||||
t.Fatalf("post request failed: %v", err)
|
||||
}
|
||||
defer postResp.Body.Close()
|
||||
|
||||
if postResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
|
||||
const token = "tok-path"
|
||||
const loginID = "user@example.com"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + token: loginID,
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
|
||||
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
|
||||
|
||||
getReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/auth/password/reset/v/"+token,
|
||||
nil,
|
||||
)
|
||||
getResp, err := app.Test(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get request failed: %v", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/v/"+token,
|
||||
nil,
|
||||
)
|
||||
postResp, err := app.Test(postReq)
|
||||
if err != nil {
|
||||
t.Fatalf("post request failed: %v", err)
|
||||
}
|
||||
defer postResp.Body.Close()
|
||||
|
||||
if postResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newResetInitAppWithErrorCodeEnricher(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Login ID is required" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
if got["code"] != "bad_request" {
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
|
||||
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||
|
||||
redis := &testRedisRepo{values: map[string]string{}}
|
||||
smsSvc := &mockSmsService{}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
SmsService: smsSvc,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "01012345678",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
|
||||
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
|
||||
}
|
||||
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
|
||||
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/response"
|
||||
"baron-sso-backend/internal/service"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
clientTenantAccessRestrictedKey = "tenant_access_restricted"
|
||||
clientAllowedTenantsKey = "allowed_tenants"
|
||||
)
|
||||
|
||||
func normalizeClientTenantAccessMetadata(metadata map[string]any) (map[string]any, error) {
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
|
||||
restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey)
|
||||
allowedTenants := normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])
|
||||
ownerTenantID := normalizeMetadataString(metadata["tenant_id"])
|
||||
|
||||
if len(allowedTenants) > 0 {
|
||||
restricted = true
|
||||
}
|
||||
|
||||
if !restricted {
|
||||
delete(metadata, clientAllowedTenantsKey)
|
||||
metadata[clientTenantAccessRestrictedKey] = false
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
if ownerTenantID != "" {
|
||||
allowedTenants = append(allowedTenants, ownerTenantID)
|
||||
}
|
||||
allowedTenants = uniqueSortedStrings(allowedTenants)
|
||||
if len(allowedTenants) == 0 {
|
||||
return nil, errors.New("allowed_tenants is required when tenant_access_restricted is enabled")
|
||||
}
|
||||
|
||||
metadata[clientTenantAccessRestrictedKey] = true
|
||||
metadata[clientAllowedTenantsKey] = allowedTenants
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func clientTenantAccessRestricted(metadata map[string]any) bool {
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
if readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey) {
|
||||
return true
|
||||
}
|
||||
return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0
|
||||
}
|
||||
|
||||
func clientAllowedTenants(metadata map[string]any) []string {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
if !clientTenantAccessRestricted(metadata) {
|
||||
return nil
|
||||
}
|
||||
return uniqueSortedStrings(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey]))
|
||||
}
|
||||
|
||||
func normalizeMetadataStringSlice(raw any) []string {
|
||||
switch value := raw.(type) {
|
||||
case []string:
|
||||
return uniqueSortedStrings(value)
|
||||
case []any:
|
||||
items := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if s, ok := item.(string); ok {
|
||||
items = append(items, s)
|
||||
}
|
||||
}
|
||||
return uniqueSortedStrings(items)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMetadataString(raw any) string {
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func uniqueSortedStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func clientTenantAccessAllowed(profile *domain.UserProfileResponse, client domain.HydraClient) bool {
|
||||
if !clientTenantAccessRestricted(client.Metadata) {
|
||||
return true
|
||||
}
|
||||
allowed := clientAllowedTenants(client.Metadata)
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
keys := manageableTenantKeysFromProfile(profile)
|
||||
if len(keys) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, tenantID := range allowed {
|
||||
if _, ok := keys[strings.ToLower(strings.TrimSpace(tenantID))]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientTenantAccessAllowedForSubtree(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse, client domain.HydraClient) bool {
|
||||
if clientTenantAccessAllowed(profile, client) {
|
||||
return true
|
||||
}
|
||||
if tenantSvc == nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
allowedTenants := make([]domain.Tenant, 0)
|
||||
for _, identifier := range clientAllowedTenants(client.Metadata) {
|
||||
if tenant, ok := resolveTenantAccessTenant(c, tenantSvc, domain.Tenant{ID: identifier, Slug: identifier}); ok {
|
||||
allowedTenants = append(allowedTenants, tenant)
|
||||
}
|
||||
}
|
||||
if len(allowedTenants) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range tenantAccessProfileTenants(profile) {
|
||||
resolvedCandidate, ok := resolveTenantAccessTenant(c, tenantSvc, candidate)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, allowed := range allowedTenants {
|
||||
if tenantMatchesOrDescendsFrom(c, tenantSvc, resolvedCandidate, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tenantAccessProfileTenants(profile *domain.UserProfileResponse) []domain.Tenant {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
tenants := make([]domain.Tenant, 0, len(profile.ManageableTenants)+len(profile.JoinedTenants)+2)
|
||||
add := func(tenant domain.Tenant) {
|
||||
key := strings.ToLower(firstNonEmptyString(tenant.ID, tenant.Slug, tenant.Name))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
tenants = append(tenants, tenant)
|
||||
}
|
||||
|
||||
if profile.Tenant != nil {
|
||||
add(*profile.Tenant)
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
add(domain.Tenant{ID: strings.TrimSpace(*profile.TenantID)})
|
||||
}
|
||||
for _, tenant := range profile.ManageableTenants {
|
||||
add(tenant)
|
||||
}
|
||||
for _, tenant := range profile.JoinedTenants {
|
||||
add(tenant)
|
||||
}
|
||||
return tenants
|
||||
}
|
||||
|
||||
func resolveTenantAccessTenant(c *fiber.Ctx, tenantSvc service.TenantService, tenant domain.Tenant) (domain.Tenant, bool) {
|
||||
if tenantSvc == nil {
|
||||
return tenant, firstNonEmptyString(tenant.ID, tenant.Slug) != ""
|
||||
}
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
if resolved, err := tenantSvc.GetTenant(c.Context(), strings.TrimSpace(tenant.ID)); err == nil && resolved != nil {
|
||||
return *resolved, true
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(tenant.Slug) != "" {
|
||||
if resolved, err := tenantSvc.GetTenantBySlug(c.Context(), strings.TrimSpace(tenant.Slug)); err == nil && resolved != nil {
|
||||
return *resolved, true
|
||||
}
|
||||
}
|
||||
return tenant, firstNonEmptyString(tenant.ID, tenant.Slug) != ""
|
||||
}
|
||||
|
||||
func tenantMatchesOrDescendsFrom(c *fiber.Ctx, tenantSvc service.TenantService, tenant domain.Tenant, ancestor domain.Tenant) bool {
|
||||
if tenantAccessTenantMatches(tenant, ancestor) {
|
||||
return true
|
||||
}
|
||||
if tenantSvc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
visited := make(map[string]struct{})
|
||||
current := tenant
|
||||
for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" {
|
||||
parentID := strings.TrimSpace(*current.ParentID)
|
||||
if _, ok := visited[parentID]; ok {
|
||||
return false
|
||||
}
|
||||
visited[parentID] = struct{}{}
|
||||
|
||||
parent, err := tenantSvc.GetTenant(c.Context(), parentID)
|
||||
if err != nil || parent == nil {
|
||||
return false
|
||||
}
|
||||
if tenantAccessTenantMatches(*parent, ancestor) {
|
||||
return true
|
||||
}
|
||||
current = *parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tenantAccessTenantMatches(left, right domain.Tenant) bool {
|
||||
leftID := strings.ToLower(strings.TrimSpace(left.ID))
|
||||
rightID := strings.ToLower(strings.TrimSpace(right.ID))
|
||||
if leftID != "" && rightID != "" && leftID == rightID {
|
||||
return true
|
||||
}
|
||||
|
||||
leftSlug := strings.ToLower(strings.TrimSpace(left.Slug))
|
||||
rightSlug := strings.ToLower(strings.TrimSpace(right.Slug))
|
||||
return leftSlug != "" && rightSlug != "" && leftSlug == rightSlug
|
||||
}
|
||||
|
||||
type tenantAccessDeniedDetails struct {
|
||||
Account tenantAccessDeniedAccount `json:"account"`
|
||||
CurrentTenant tenantAccessDeniedTenant `json:"current_tenant"`
|
||||
AffiliatedTenants []tenantAccessDeniedTenant `json:"affiliated_tenants,omitempty"`
|
||||
AllowedTenants []tenantAccessDeniedTenant `json:"allowed_tenants,omitempty"`
|
||||
}
|
||||
|
||||
type tenantAccessDeniedAccount struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
type tenantAccessDeniedTenant struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
}
|
||||
|
||||
func tenantNotAllowedError(c *fiber.Ctx, details tenantAccessDeniedDetails) error {
|
||||
return response.ErrorWithDetails(
|
||||
c,
|
||||
fiber.StatusForbidden,
|
||||
"tenant_not_allowed",
|
||||
"허용되지 않은 테넌트입니다.",
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
func isClientTenantAccessAllowed(profile *domain.UserProfileResponse, client domain.HydraClient) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
}
|
||||
return clientTenantAccessAllowed(profile, client)
|
||||
}
|
||||
|
||||
func enforceClientTenantAccess(c *fiber.Ctx, tenantSvc service.TenantService, client domain.HydraClient, profile *domain.UserProfileResponse, resolveErr error) bool {
|
||||
if !clientTenantAccessRestricted(client.Metadata) {
|
||||
return false
|
||||
}
|
||||
details := buildTenantAccessDeniedDetails(c, tenantSvc, client, profile)
|
||||
if resolveErr != nil || profile == nil {
|
||||
_ = tenantNotAllowedError(c, details)
|
||||
return true
|
||||
}
|
||||
if !clientTenantAccessAllowedForSubtree(c, tenantSvc, profile, client) {
|
||||
_ = tenantNotAllowedError(c, details)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildTenantAccessDeniedDetails(c *fiber.Ctx, tenantSvc service.TenantService, client domain.HydraClient, profile *domain.UserProfileResponse) tenantAccessDeniedDetails {
|
||||
details := tenantAccessDeniedDetails{
|
||||
Account: tenantAccessDeniedAccount{Email: strings.TrimSpace(profileEmail(profile))},
|
||||
CurrentTenant: resolveCurrentTenantDetails(c, tenantSvc, profile),
|
||||
AffiliatedTenants: resolveAffiliatedTenantDetails(c, tenantSvc, profile),
|
||||
}
|
||||
|
||||
for _, identifier := range clientAllowedTenants(client.Metadata) {
|
||||
details.AllowedTenants = append(details.AllowedTenants, resolveAllowedTenantDetails(c, tenantSvc, identifier))
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func resolveAffiliatedTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse) []tenantAccessDeniedTenant {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]tenantAccessDeniedTenant, 0, len(profile.JoinedTenants)+1)
|
||||
appendTenant := func(tenant tenantAccessDeniedTenant) {
|
||||
key := strings.ToLower(firstNonEmptyString(tenant.ID, tenant.Slug, tenant.Identifier, tenant.Name))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, tenant)
|
||||
}
|
||||
|
||||
appendTenant(resolveCurrentTenantDetails(c, tenantSvc, profile))
|
||||
|
||||
for _, joined := range profile.JoinedTenants {
|
||||
appendTenant(tenantAccessDeniedTenant{
|
||||
ID: strings.TrimSpace(joined.ID),
|
||||
Slug: strings.TrimSpace(joined.Slug),
|
||||
Name: strings.TrimSpace(joined.Name),
|
||||
Identifier: firstNonEmptyString(strings.TrimSpace(joined.Slug), strings.TrimSpace(joined.ID)),
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveCurrentTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse) tenantAccessDeniedTenant {
|
||||
if profile == nil {
|
||||
return tenantAccessDeniedTenant{}
|
||||
}
|
||||
|
||||
if profile.Tenant != nil {
|
||||
return tenantAccessDeniedTenant{
|
||||
ID: strings.TrimSpace(profile.Tenant.ID),
|
||||
Slug: strings.TrimSpace(profile.Tenant.Slug),
|
||||
Name: strings.TrimSpace(profile.Tenant.Name),
|
||||
Identifier: firstNonEmptyString(strings.TrimSpace(profile.Tenant.Slug), strings.TrimSpace(profile.Tenant.ID)),
|
||||
}
|
||||
}
|
||||
|
||||
if tenantSvc != nil {
|
||||
if profile.TenantID != nil && strings.TrimSpace(*profile.TenantID) != "" {
|
||||
if tenant, err := tenantSvc.GetTenant(c.Context(), strings.TrimSpace(*profile.TenantID)); err == nil && tenant != nil {
|
||||
return tenantAccessDeniedTenant{
|
||||
ID: strings.TrimSpace(tenant.ID),
|
||||
Slug: strings.TrimSpace(tenant.Slug),
|
||||
Name: strings.TrimSpace(tenant.Name),
|
||||
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tenantAccessDeniedTenant{
|
||||
ID: strings.TrimSpace(pointerValue(profile.TenantID)),
|
||||
Identifier: strings.TrimSpace(pointerValue(profile.TenantID)),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAllowedTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, identifier string) tenantAccessDeniedTenant {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return tenantAccessDeniedTenant{}
|
||||
}
|
||||
|
||||
if tenantSvc != nil {
|
||||
if tenant, err := tenantSvc.GetTenant(c.Context(), identifier); err == nil && tenant != nil {
|
||||
return tenantAccessDeniedTenant{
|
||||
ID: strings.TrimSpace(tenant.ID),
|
||||
Slug: strings.TrimSpace(tenant.Slug),
|
||||
Name: strings.TrimSpace(tenant.Name),
|
||||
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID), identifier),
|
||||
}
|
||||
}
|
||||
if tenant, err := tenantSvc.GetTenantBySlug(c.Context(), identifier); err == nil && tenant != nil {
|
||||
return tenantAccessDeniedTenant{
|
||||
ID: strings.TrimSpace(tenant.ID),
|
||||
Slug: strings.TrimSpace(tenant.Slug),
|
||||
Name: strings.TrimSpace(tenant.Name),
|
||||
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID), identifier),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tenantAccessDeniedTenant{Identifier: identifier}
|
||||
}
|
||||
|
||||
func profileEmail(profile *domain.UserProfileResponse) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
return profile.Email
|
||||
}
|
||||
|
||||
func pointerValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type clientStructuredScope struct {
|
||||
Name string `json:"name"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
func mergeRequestedScopesWithClientRequirements(client domain.HydraClient, requested []string) []string {
|
||||
combined := make([]string, 0, len(requested)+2)
|
||||
combined = append(combined, requested...)
|
||||
combined = append(combined, requiredClientScopes(client)...)
|
||||
|
||||
return normalizeScopesInConsentOrder(combined)
|
||||
}
|
||||
|
||||
func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
combined := make([]string, 0, len(scopes))
|
||||
combined = append(combined, scopes...)
|
||||
|
||||
seen := make(map[string]struct{}, len(combined))
|
||||
out := make([]string, 0, len(combined))
|
||||
|
||||
appendIfPresent := func(scope string) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
return
|
||||
}
|
||||
for _, candidate := range combined {
|
||||
if strings.TrimSpace(candidate) != scope {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
out = append(out, scope)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
appendIfPresent("openid")
|
||||
appendIfPresent("tenant")
|
||||
|
||||
for _, scope := range combined {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
out = append(out, scope)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func requiredClientScopes(client domain.HydraClient) []string {
|
||||
required := make([]string, 0, 4)
|
||||
if clientTenantAccessRestricted(client.Metadata) {
|
||||
required = append(required, "tenant")
|
||||
}
|
||||
|
||||
if client.Metadata == nil {
|
||||
return normalizeScopesInConsentOrder(required)
|
||||
}
|
||||
|
||||
rawStructuredScopes, ok := client.Metadata["structured_scopes"]
|
||||
if !ok || rawStructuredScopes == nil {
|
||||
return normalizeScopesInConsentOrder(required)
|
||||
}
|
||||
|
||||
rawBytes, err := json.Marshal(rawStructuredScopes)
|
||||
if err != nil {
|
||||
return normalizeScopesInConsentOrder(required)
|
||||
}
|
||||
|
||||
var scopes []clientStructuredScope
|
||||
if err := json.Unmarshal(rawBytes, &scopes); err != nil {
|
||||
return normalizeScopesInConsentOrder(required)
|
||||
}
|
||||
|
||||
for _, scope := range scopes {
|
||||
name := strings.TrimSpace(scope.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if scope.Mandatory || scope.Locked {
|
||||
required = append(required, name)
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeScopesInConsentOrder(required)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestCreateClient_NormalizesTenantAccessMetadata(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
ownerTenantID := "tenant-owner"
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, json.Unmarshal(body, &captured))
|
||||
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
||||
"client_id": captured.ClientID,
|
||||
"client_name": captured.ClientName,
|
||||
"redirect_uris": captured.RedirectURIs,
|
||||
"grant_types": captured.GrantTypes,
|
||||
"response_types": captured.ResponseTypes,
|
||||
"scope": captured.Scope,
|
||||
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
||||
"metadata": captured.Metadata,
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
TenantID: &ownerTenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"id": "client-tenant",
|
||||
"name": "Tenant Client",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/cb"},
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-b", "tenant-a", "tenant-b"},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.True(t, clientTenantAccessRestricted(captured.Metadata))
|
||||
assert.Equal(t, []string{"tenant-a", "tenant-b", "tenant-owner"}, clientAllowedTenants(captured.Metadata))
|
||||
}
|
||||
|
||||
func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) {
|
||||
hydraCalled := false
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
hydraCalled = true
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"id": "client-tenant",
|
||||
"name": "Tenant Client",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/cb"},
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) {
|
||||
client := domain.HydraClient{
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"})
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile"}, merged)
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant":
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-tenant",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"skip": false,
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-tenant",
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-b"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
case r.URL.Host == "kratos.test" && r.URL.Path == "/sessions/whoami":
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@test.com",
|
||||
"tenant_id": "tenant-a",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
||||
|
||||
t.Setenv("APP_ENV", "dev")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-tenant", nil)
|
||||
req.Header.Set("X-Mock-Role", "user")
|
||||
req.Header.Set("X-Tenant-ID", "tenant-a")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, "tenant_not_allowed", body["code"])
|
||||
details, ok := body["details"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
account, ok := details["account"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, account["email"])
|
||||
currentTenant, ok := details["current_tenant"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, currentTenant["identifier"])
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_DeniesRestrictedClientWhenProfileResolutionFails(t *testing.T) {
|
||||
acceptCalled := false
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-profile-missing":
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-profile-missing",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"skip": false,
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-tenant",
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-b"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
case r.URL.Path == "/oauth2/auth/requests/consent/accept":
|
||||
acceptCalled = true
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
default:
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: func() service.KratosAdminService {
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"tenant_id": "tenant-a",
|
||||
"companyCode": "tenant-a",
|
||||
},
|
||||
}, nil).Once()
|
||||
return mockKratos
|
||||
}(),
|
||||
TenantService: func() service.TenantService {
|
||||
tenantSvc := new(MockTenantService)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-a").Return(&domain.Tenant{
|
||||
ID: "tenant-a",
|
||||
Slug: "tenant-a",
|
||||
Name: "Tenant A",
|
||||
}, nil)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-c").Return(&domain.Tenant{
|
||||
ID: "tenant-c",
|
||||
Slug: "tenant-c",
|
||||
Name: "Tenant C",
|
||||
}, nil)
|
||||
tenantSvc.On("ListJoinedTenants", mock.Anything, "user-123").Return([]domain.Tenant{
|
||||
{ID: "tenant-a", Slug: "tenant-a", Name: "Tenant A"},
|
||||
{ID: "tenant-c", Slug: "tenant-c", Name: "Tenant C"},
|
||||
}, nil).Once()
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError)
|
||||
tenantSvc.On("GetTenantBySlug", mock.Anything, "tenant-b").Return(&domain.Tenant{
|
||||
ID: "tenant-b-id",
|
||||
Slug: "tenant-b",
|
||||
Name: "Tenant B",
|
||||
}, nil)
|
||||
return tenantSvc
|
||||
}(),
|
||||
ConsentRepo: &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "client-tenant",
|
||||
Subject: "user-123",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-profile-missing", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=invalid-session")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.False(t, acceptCalled)
|
||||
|
||||
var body map[string]any
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, "tenant_not_allowed", body["code"])
|
||||
|
||||
details, ok := body["details"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
account, ok := details["account"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user@test.com", account["email"])
|
||||
currentTenant, ok := details["current_tenant"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Tenant A", currentTenant["name"])
|
||||
affiliatedTenants, ok := details["affiliated_tenants"].([]any)
|
||||
assert.True(t, ok)
|
||||
assert.Len(t, affiliatedTenants, 2)
|
||||
}
|
||||
|
||||
func TestAcceptOidcLoginRequest_DeniesTenantAccess(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/deny", func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-a"
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-123",
|
||||
Role: domain.RoleUser,
|
||||
Email: "user@test.com",
|
||||
TenantID: &tenantID,
|
||||
CompanyCode: "tenant-a",
|
||||
JoinedTenants: []domain.Tenant{
|
||||
{ID: "tenant-a", Slug: "tenant-a", Name: "Tenant A"},
|
||||
{ID: "tenant-c", Slug: "tenant-c", Name: "Tenant C"},
|
||||
},
|
||||
}
|
||||
client := domain.HydraClient{
|
||||
ClientID: "client-tenant",
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-b"},
|
||||
},
|
||||
}
|
||||
tenantSvc := new(MockTenantService)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-a").Return(&domain.Tenant{
|
||||
ID: "tenant-a",
|
||||
Slug: "tenant-a",
|
||||
Name: "Tenant A",
|
||||
}, nil)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-c").Return(&domain.Tenant{
|
||||
ID: "tenant-c",
|
||||
Slug: "tenant-c",
|
||||
Name: "Tenant C",
|
||||
}, nil)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError)
|
||||
tenantSvc.On("GetTenantBySlug", mock.Anything, "tenant-b").Return(&domain.Tenant{
|
||||
ID: "tenant-b-id",
|
||||
Slug: "tenant-b",
|
||||
Name: "Tenant B",
|
||||
}, nil)
|
||||
enforceClientTenantAccess(c, tenantSvc, client, profile, nil)
|
||||
return nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/deny", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, "tenant_not_allowed", body["code"])
|
||||
|
||||
details, ok := body["details"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
|
||||
account, ok := details["account"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user@test.com", account["email"])
|
||||
|
||||
currentTenant, ok := details["current_tenant"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Tenant A", currentTenant["name"])
|
||||
affiliatedTenants, ok := details["affiliated_tenants"].([]any)
|
||||
assert.True(t, ok)
|
||||
assert.Len(t, affiliatedTenants, 2)
|
||||
|
||||
allowedTenants, ok := details["allowed_tenants"].([]any)
|
||||
assert.True(t, ok)
|
||||
assert.Len(t, allowedTenants, 1)
|
||||
allowedTenant, ok := allowedTenants[0].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Tenant B", allowedTenant["name"])
|
||||
}
|
||||
|
||||
func TestAcceptOidcLoginRequest_AllowsRestrictedClientForHanmacFamilyDescendant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/allow-descendant", func(c *fiber.Ctx) error {
|
||||
hanmacFamilyID := "hanmac-family-id"
|
||||
samanID := "saman-id"
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-123",
|
||||
Role: domain.RoleUser,
|
||||
Email: "user@samaneng.com",
|
||||
TenantID: &samanID,
|
||||
Tenant: &domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
ParentID: &hanmacFamilyID,
|
||||
},
|
||||
JoinedTenants: []domain.Tenant{
|
||||
{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
ParentID: &hanmacFamilyID,
|
||||
},
|
||||
},
|
||||
}
|
||||
client := domain.HydraClient{
|
||||
ClientID: "orgfront",
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"hanmac-family"},
|
||||
},
|
||||
}
|
||||
tenantSvc := new(MockTenantService)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "hanmac-family").Return(nil, assert.AnError).Maybe()
|
||||
tenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac-family").Return(&domain.Tenant{
|
||||
ID: hanmacFamilyID,
|
||||
Slug: "hanmac-family",
|
||||
Name: "한맥가족",
|
||||
}, nil).Maybe()
|
||||
tenantSvc.On("GetTenant", mock.Anything, samanID).Return(&domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
ParentID: &hanmacFamilyID,
|
||||
}, nil).Maybe()
|
||||
tenantSvc.On("GetTenant", mock.Anything, hanmacFamilyID).Return(&domain.Tenant{
|
||||
ID: hanmacFamilyID,
|
||||
Slug: "hanmac-family",
|
||||
Name: "한맥가족",
|
||||
}, nil).Maybe()
|
||||
|
||||
blocked := enforceClientTenantAccess(c, tenantSvc, client, profile, nil)
|
||||
assert.False(t, blocked)
|
||||
return c.SendStatus(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/allow-descendant", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Mock IDP Provider ---
|
||||
|
||||
type mockIdpProvider struct {
|
||||
userExists bool
|
||||
name string
|
||||
signInInfo *domain.AuthInfo
|
||||
issueSession *domain.AuthInfo
|
||||
verifyCodeInfo *domain.AuthInfo
|
||||
err error
|
||||
initiateLinkErr error
|
||||
updateCalled bool
|
||||
updateCallCount int
|
||||
updatedLoginID string
|
||||
updatedPassword string
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) Name() string {
|
||||
if m.name != "" {
|
||||
return m.name
|
||||
}
|
||||
return "mock-idp"
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) { return nil, m.err }
|
||||
func (m *mockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
return "mock-user-id", m.err
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
return m.signInInfo, m.err
|
||||
}
|
||||
func (m *mockIdpProvider) UserExists(loginID string) (bool, error) { return m.userExists, m.err }
|
||||
func (m *mockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
if m.issueSession != nil {
|
||||
return m.issueSession, m.err
|
||||
}
|
||||
return &domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "valid-sid"},
|
||||
}, m.err
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
if m.initiateLinkErr != nil {
|
||||
return nil, m.initiateLinkErr
|
||||
}
|
||||
return &domain.LinkLoginInit{FlowID: "mock-flow-id", Mode: "code"}, m.err
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return m.verifyCodeInfo, m.err
|
||||
}
|
||||
func (m *mockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return nil, m.err }
|
||||
func (m *mockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return m.err }
|
||||
func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return nil, m.err
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
m.updateCalled = true
|
||||
m.updateCallCount++
|
||||
m.updatedLoginID = loginID
|
||||
m.updatedPassword = newPassword
|
||||
return m.err
|
||||
}
|
||||
|
||||
// --- Mock Audit Repository ---
|
||||
|
||||
type mockAuditRepo struct {
|
||||
logs []domain.AuditLog
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) Create(log *domain.AuditLog) error {
|
||||
m.logs = append(m.logs, *log)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
|
||||
return m.logs, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) {
|
||||
var results []domain.AuditLog
|
||||
for _, log := range m.logs {
|
||||
if log.UserID == userID {
|
||||
if slices.Contains(eventTypes, log.EventType) {
|
||||
results = append(results, log)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
||||
|
||||
type mockRPUsageEventSink struct {
|
||||
events []domain.RPUsageEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRPUsageEventSink) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.events = append(m.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockOathkeeperRepo struct {
|
||||
logs []domain.OathkeeperAccessLog
|
||||
}
|
||||
|
||||
func (m *mockOathkeeperRepo) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) {
|
||||
if subject == "" {
|
||||
return m.logs, nil
|
||||
}
|
||||
results := make([]domain.OathkeeperAccessLog, 0, len(m.logs))
|
||||
for _, log := range m.logs {
|
||||
if log.Subject == subject {
|
||||
results = append(results, log)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *mockOathkeeperRepo) Ping(ctx context.Context) error { return nil }
|
||||
|
||||
// --- Mock Consent Repository ---
|
||||
|
||||
type mockConsentRepo struct {
|
||||
consents []domain.ClientConsent
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
|
||||
m.consents = append(m.consents, *consent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
|
||||
var results []domain.ClientConsent
|
||||
for _, c := range m.consents {
|
||||
if c.Subject == subject {
|
||||
results = append(results, c)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) {
|
||||
seen := map[string]struct{}{}
|
||||
subjects := make([]string, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[consent.Subject]; ok {
|
||||
continue
|
||||
}
|
||||
seen[consent.Subject] = struct{}{}
|
||||
subjects = append(subjects, consent.Subject)
|
||||
}
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) {
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID == clientID && consent.Subject == subject {
|
||||
found := consent
|
||||
return &found, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
for _, consent := range m.consents {
|
||||
if consent.Subject == subject && (clientID == "" || consent.ClientID == clientID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, consent)
|
||||
}
|
||||
m.consents = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID != clientID {
|
||||
filtered = append(filtered, consent)
|
||||
}
|
||||
}
|
||||
m.consents = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID == clientID {
|
||||
results = append(results, domain.ClientConsentWithTenantInfo{ClientConsent: consent})
|
||||
}
|
||||
}
|
||||
return results, int64(len(results)), nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID == clientID {
|
||||
results = append(results, domain.ClientConsentWithTenantInfo{
|
||||
ClientConsent: consent,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results, int64(len(results)), nil
|
||||
}
|
||||
|
||||
// --- Mock Secret Repository ---
|
||||
|
||||
type mockSecretRepo struct {
|
||||
secrets map[string]string
|
||||
}
|
||||
|
||||
func (m *mockSecretRepo) Upsert(ctx context.Context, clientID, secret string) error {
|
||||
if m.secrets == nil {
|
||||
m.secrets = make(map[string]string)
|
||||
}
|
||||
m.secrets[clientID] = secret
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSecretRepo) GetByID(ctx context.Context, clientID string) (string, error) {
|
||||
return m.secrets[clientID], nil
|
||||
}
|
||||
|
||||
func (m *mockSecretRepo) Delete(ctx context.Context, clientID string) error {
|
||||
delete(m.secrets, clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- HTTP Mock Helpers ---
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func setDefaultHTTPClientForTest(t interface{ Cleanup(func()) }, transport http.RoundTripper) {
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = &http.Client{Transport: transport}
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient = origDefault
|
||||
})
|
||||
}
|
||||
|
||||
func httpResponse(r *http.Request, code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
Request: r,
|
||||
}
|
||||
}
|
||||
|
||||
func httpJSONAny(r *http.Request, code int, data any) *http.Response {
|
||||
body, _ := json.Marshal(data)
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewBuffer(body)),
|
||||
Request: r,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestDevHandler_Isolation(t *testing.T) {
|
||||
createHandler := func(mockKeto *devMockKetoService) *DevHandler {
|
||||
return &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||
{
|
||||
"client_id": "client-tenant-a",
|
||||
"client_name": "App Tenant A",
|
||||
"token_endpoint_auth_method": "none", // PKCE
|
||||
"metadata": map[string]any{"tenant_id": "tenant-a"},
|
||||
},
|
||||
{
|
||||
"client_id": "client-tenant-b",
|
||||
"client_name": "App Tenant B",
|
||||
"token_endpoint_auth_method": "none", // PKCE
|
||||
"metadata": map[string]any{"tenant_id": "tenant-b"},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||
tenantID := "tenant-a"
|
||||
if id == "client-tenant-b" {
|
||||
tenantID = "tenant-b"
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": id,
|
||||
"client_name": "App " + id,
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"tenant_id": tenantID},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}),
|
||||
},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Local bypass should be removed", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5174")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("ListClients should show all for SuperAdmin", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "super-user",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res struct {
|
||||
Items []clientSummary `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
// Should see both clients
|
||||
assert.Equal(t, 2, len(res.Items))
|
||||
})
|
||||
|
||||
t.Run("ListClients should filter by permit for non-SuperAdmin", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
// Explicit permission for private client check bypass
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Maybe()
|
||||
// Mock permit for the specific client
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(true, nil).Maybe()
|
||||
// Deny for other clients
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-b", "view").Return(false, nil).Maybe()
|
||||
|
||||
mockKeto.On("ListRelations", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res struct {
|
||||
Items []clientSummary `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
// Should only see client-tenant-a (tenant permit)
|
||||
assert.Equal(t, 1, len(res.Items))
|
||||
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Tenant member should see empty list from DevFront clients if no relation", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-member",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
// Deny all by default
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res struct {
|
||||
Items []clientSummary `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
// Empty list because we didn't mock any specific 'view' permissions for this user
|
||||
assert.Equal(t, 0, len(res.Items))
|
||||
})
|
||||
|
||||
t.Run("GetClient should enforce isolation for non-SuperAdmin", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
// Case 1: Same tenant BUT no permit (Normal users need permit now)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(false, nil).Once()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// Case 2: Same tenant WITH permit
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(true, nil).Maybe()
|
||||
mockKeto.On("ListRelations", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Case 3: Different tenant
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-b", "view").Return(false, nil).Maybe()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-b", nil)
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("CreateClient should record user_id and tenant_id", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleSuperAdmin, // Bypass for creation permission
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"client_name": "New App",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"http://localhost/cb"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Tenant-ID", "tenant-a")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var res clientDetailResponse
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Equal(t, "tenant-a", res.Client.Metadata["tenant_id"])
|
||||
assert.Equal(t, "user-a", res.Client.Metadata["user_id"])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type devMockRPUserMetadataRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockRPUserMetadataRepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
|
||||
args := m.Called(ctx, clientID, userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.RPUserMetadata), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockRPUserMetadataRepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
|
||||
args := m.Called(ctx, metadata)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
|
||||
return row.ClientID == "client-1" &&
|
||||
row.UserID == "user-1" &&
|
||||
row.Metadata["approvalLevel"] == "A" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin"
|
||||
})).Return(nil).Once()
|
||||
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
UserID: "user-1",
|
||||
Metadata: domain.JSONMap{"approvalLevel": "A"},
|
||||
}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
app.Get("/api/v1/dev/clients/:id/users/:userId/metadata", h.GetRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"approvalLevel": "A",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
})
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
putReq.Header.Set("Content-Type", "application/json")
|
||||
putResp, _ := app.Test(putReq, -1)
|
||||
assert.Equal(t, http.StatusOK, putResp.StatusCode)
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/users/user-1/metadata", nil)
|
||||
getResp, _ := app.Test(getReq, -1)
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
var got map[string]any
|
||||
assert.NoError(t, json.NewDecoder(getResp.Body).Decode(&got))
|
||||
assert.Equal(t, "client-1", got["clientId"])
|
||||
assert.Equal(t, "user-1", got["userId"])
|
||||
assert.Equal(t, "A", got["metadata"].(map[string]any)["approvalLevel"])
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Once()
|
||||
kratos := new(MockKratosAdmin)
|
||||
kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User One",
|
||||
},
|
||||
}, nil).Once()
|
||||
var capturedTraits map[string]any
|
||||
kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "B"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
rpClaims := capturedTraits["rp_custom_claims"].(map[string]any)
|
||||
clientClaims := rpClaims["client-1"].(domain.JSONMap)
|
||||
require.Equal(t, "B", clientClaims["approvalLevel"])
|
||||
require.Equal(t, map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
}, clientClaims["approvalLevel_permissions"])
|
||||
repo.AssertExpectations(t)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsUndefinedClaimKey(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"unknown_claim": "A"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsInvalidTypedClaimValue(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"contract_date": "2026/06/09"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user