1
0
forked from baron/baron-sso

Merge pull request 'dev/ory-hydra2' (#208) from dev/ory-hydra2 into main

Reviewed-on: ai-team/baron-sso#208
This commit is contained in:
2026-02-06 11:31:22 +09:00
11 changed files with 427 additions and 105 deletions

View File

@@ -253,11 +253,12 @@ func main() {
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
tenantHandler := handler.NewTenantHandler(db, tenantService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
kratosAdminService := service.NewKratosAdminService()

View File

@@ -57,6 +57,7 @@ require (
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.11.1 // 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

View File

@@ -110,6 +110,8 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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=

View File

@@ -37,7 +37,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},
&domain.ClientConsent{},
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
)
}

View File

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

View File

@@ -89,6 +89,7 @@ type AuthHandler struct {
TenantService service.TenantService
KetoService service.KetoService
UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository
}
type signupState struct {
@@ -146,7 +147,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
return false, int(interval.Seconds())
}
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *AuthHandler {
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
return &AuthHandler{
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
@@ -159,6 +160,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
TenantService: tenantService,
KetoService: ketoService,
UserRepo: userRepo,
ConsentRepo: consentRepo,
}
}
@@ -3425,6 +3427,15 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
slog.Error("failed to auto-accept hydra consent request", "error", err)
// 자동 승인 실패 시 일반 흐름으로 진행
} else {
// [New] Sync to local DB even on auto-accept to ensure data consistency
if h.ConsentRepo != nil {
consent := &domain.ClientConsent{
ClientID: consentRequest.Client.ClientID,
Subject: consentRequest.Subject,
GrantedScopes: consentRequest.RequestedScope,
}
_ = h.ConsentRepo.Upsert(c.Context(), consent)
}
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID)
return c.JSON(acceptResp)
}
@@ -3538,6 +3549,19 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request")
}
// [New] Sync to local DB for "List All Consents" feature
if h.ConsentRepo != nil {
consent := &domain.ClientConsent{
ClientID: consentRequest.Client.ClientID,
Subject: consentRequest.Subject,
GrantedScopes: consentRequest.RequestedScope,
}
if err := h.ConsentRepo.Upsert(c.Context(), consent); err != nil {
slog.Error("failed to sync consent to local DB", "error", err, "subject", consent.Subject, "client", consent.ClientID)
// Don't fail the whole request, but log it
}
}
if h.AuditRepo != nil {
detailsMap := map[string]interface{}{
"client_id": consentRequest.Client.ClientID,

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"errors"
@@ -13,16 +14,20 @@ import (
)
type DevHandler struct {
Hydra *service.HydraAdminService
Redis *service.RedisService
SecretRepo domain.ClientSecretRepository
Hydra *service.HydraAdminService
Redis *service.RedisService
SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService
ConsentRepo repository.ClientConsentRepository
}
func NewDevHandler(redis *service.RedisService, secretRepo domain.ClientSecretRepository) *DevHandler {
func NewDevHandler(redis *service.RedisService, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
return &DevHandler{
Hydra: service.NewHydraAdminService(),
Redis: redis,
SecretRepo: secretRepo,
Hydra: service.NewHydraAdminService(),
Redis: redis,
SecretRepo: secretRepo,
KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo,
}
}
@@ -58,11 +63,15 @@ type clientEndpoints struct {
}
type consentSummary struct {
Subject string `json:"subject"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName,omitempty"`
GrantedScopes []string `json:"grantedScopes"`
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
Subject string `json:"subject"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName,omitempty"`
GrantedScopes []string `json:"grantedScopes"`
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
TenantID string `json:"tenantId,omitempty"`
TenantName string `json:"tenantName,omitempty"`
}
type consentListResponse struct {
@@ -391,45 +400,84 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
}
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
subject := strings.TrimSpace(c.Query("subject"))
if subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
}
clientID := strings.TrimSpace(c.Query("client_id"))
if clientID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
}
subject := strings.TrimSpace(c.Query("subject"))
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
if limit <= 0 {
limit = 50
}
// [Isolation] Get admin tenant ID from header or locals
adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev
var consents []domain.ClientConsentWithTenantInfo
var total int64
var err error
if subject != "" {
// Resolve subject if it's email/name (Legacy support)
if _, err := uuid.Parse(subject); err != nil {
resolved, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
if resolved != "" {
subject = resolved
}
}
// Single user fetch from Hydra (to get latest status) or Local DB
// Issue says: "List All", so we prefer Local DB for consistency in listing
// But for a single user, we could still use Hydra.
// Let's use Local DB to support tenant filtering even for search.
// For simplicity, we just filter the list later if search is used.
}
if adminTenantID != "" {
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset)
} else {
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset)
}
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, clientID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items := make([]consentSummary, 0, len(sessions))
for _, session := range sessions {
client := session.Client
if client.ClientID == "" && session.ConsentRequest != nil {
client = session.ConsentRequest.Client
items := make([]consentSummary, 0, len(consents))
for _, consent := range consents {
// Filter by subject if search is active
if subject != "" && consent.Subject != subject {
continue
}
subject := session.Subject
if subject == "" && session.ConsentRequest != nil {
subject = session.ConsentRequest.Subject
}
authAt := ""
if session.AuthenticatedAt != nil {
authAt = session.AuthenticatedAt.Format(time.RFC3339)
} else if session.RequestedAt != nil {
authAt = session.RequestedAt.Format(time.RFC3339)
} else if session.HandledAt != nil {
authAt = session.HandledAt.Format(time.RFC3339)
userName := ""
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject)
if err == nil && identity != nil {
if name, ok := identity.Traits["name"].(string); ok {
userName = name
} else if email, ok := identity.Traits["email"].(string); ok {
userName = email
}
}
items = append(items, consentSummary{
Subject: subject,
ClientID: client.ClientID,
ClientName: client.ClientName,
GrantedScopes: session.GrantedScope,
AuthenticatedAt: authAt,
Subject: consent.Subject,
UserName: userName,
ClientID: consent.ClientID,
GrantedScopes: consent.GrantedScopes,
AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339),
CreatedAt: consent.CreatedAt,
TenantID: consent.TenantID,
TenantName: consent.TenantName,
})
}
return c.JSON(consentListResponse{Items: items})
return c.JSON(fiber.Map{
"items": items,
"total": total,
})
}
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
@@ -439,10 +487,24 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
}
clientID := strings.TrimSpace(c.Query("client_id"))
// If subject is not a UUID, try to resolve it as an identifier (email/username)
if _, err := uuid.Parse(subject); err != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
if err == nil && resolved != "" {
subject = resolved
}
}
// 1. Revoke in Hydra
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// 2. Sync to Local DB (Delete)
if h.ConsentRepo != nil {
_ = h.ConsentRepo.Delete(c.Context(), subject, clientID)
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -0,0 +1,92 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"gorm.io/gorm"
)
type ClientConsentRepository interface {
Upsert(ctx context.Context, consent *domain.ClientConsent) error
Delete(ctx context.Context, subject, clientID string) error
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
}
type clientConsentRepo struct {
db *gorm.DB
}
func NewClientConsentRepository(db *gorm.DB) ClientConsentRepository {
return &clientConsentRepo{db: db}
}
func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
return r.db.WithContext(ctx).
Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject).
Assign(map[string]interface{}{
"granted_scopes": consent.GrantedScopes,
"updated_at": gorm.Expr("NOW()"),
}).
FirstOrCreate(consent).Error
}
func (r *clientConsentRepo) Delete(ctx context.Context, subject, clientID string) error {
return r.db.WithContext(ctx).
Where("subject = ? AND client_id = ?", subject, clientID).
Delete(&domain.ClientConsent{}).Error
}
func (r *clientConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
var consents []domain.ClientConsentWithTenantInfo
var total int64
// Base query for counting
countQuery := r.db.WithContext(ctx).Model(&domain.ClientConsent{}).Where("client_id = ?", clientID)
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
// Query for fetching data
query := r.db.WithContext(ctx).
Model(&domain.ClientConsent{}).
Select("client_consents.*, users.tenant_id, tenants.name as tenant_name").
Joins("LEFT JOIN users ON users.id::text = client_consents.subject").
Joins("LEFT JOIN tenants ON tenants.id = users.tenant_id").
Where("client_consents.client_id = ?", clientID)
err := query.Limit(limit).Offset(offset).Order("client_consents.updated_at DESC").Scan(&consents).Error
return consents, total, err
}
func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
var consents []domain.ClientConsentWithTenantInfo
var total int64
// Base query for counting
countQuery := r.db.WithContext(ctx).
Model(&domain.ClientConsent{}).
Joins("JOIN users ON users.id::text = client_consents.subject").
Where("client_consents.client_id = ? AND users.tenant_id = ?", clientID, tenantID)
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
// Query for fetching data
query := r.db.WithContext(ctx).
Model(&domain.ClientConsent{}).
Select("client_consents.*, users.tenant_id, tenants.name as tenant_name").
Joins("JOIN users ON users.id::text = client_consents.subject").
Joins("JOIN tenants ON tenants.id = users.tenant_id").
Where("client_consents.client_id = ? AND users.tenant_id = ?", clientID, tenantID)
err := query.
Limit(limit).
Offset(offset).
Order("client_consents.updated_at DESC").
Scan(&consents).Error
return consents, total, err
}

View File

@@ -46,7 +46,7 @@ function ClientConsentsPage() {
} = useQuery({
queryKey: ["consents", clientId, subject],
queryFn: () => fetchConsents(subject, clientId),
enabled: subject.length > 0,
enabled: clientId.length > 0, // Removed subject.length > 0 check
});
const revokeMutation = useMutation({
mutationFn: (payload: { subject: string }) =>
@@ -173,86 +173,106 @@ function ClientConsentsPage() {
Loading consents...
</CardContent>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Tenant</TableHead>
<TableHead>Status</TableHead>
<TableHead>Granted Scopes</TableHead>
<TableHead>First Granted</TableHead>
<TableHead>Last Authenticated</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={`${row.subject}-${row.clientId}`}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{row.subject.slice(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.clientName || "Subject"}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="success">Active</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.grantedScopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="border bg-muted/40 text-foreground"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.authenticatedAt || "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
className="text-destructive"
onClick={() =>
revokeMutation.mutate({ subject: row.subject })
}
>
Revoke
</Button>
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
No consents found.
</TableCell>
</TableRow>
))}
) : (
rows.map((row) => (
<TableRow key={`${row.subject}-${row.clientId}`}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{(row.userName || row.subject).slice(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.userName || "Subject"}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.tenantName || "N/A"}
</span>
<span className="text-xs text-muted-foreground">
{row.tenantId}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant="success">Active</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.grantedScopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="border bg-muted/40 text-foreground"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.authenticatedAt
? new Date(row.authenticatedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
className="text-destructive"
onClick={() =>
revokeMutation.mutate({ subject: row.subject })
}
>
Revoke
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
<p>
Showing <span className="font-semibold text-foreground">1</span> to{" "}
<span className="font-semibold text-foreground">4</span> of{" "}
<span className="font-semibold text-foreground">1,250</span> users
Showing <span className="font-semibold text-foreground">{rows.length > 0 ? 1 : 0}</span> to{" "}
<span className="font-semibold text-foreground">{rows.length}</span> of{" "}
<span className="font-semibold text-foreground">{rows.length}</span> users
</p>
<div className="flex gap-2">
<Button variant="outline" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm">1</Button>
<Button variant="ghost" size="sm">
2
</Button>
<Button variant="ghost" size="sm">
3
</Button>
<Button variant="outline" size="icon">
<Button size="sm" disabled={rows.length === 0}>1</Button>
<Button variant="outline" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
@@ -265,7 +285,7 @@ function ClientConsentsPage() {
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Active Grants
</p>
<CardTitle className="text-2xl font-black">1,250</CardTitle>
<CardTitle className="text-2xl font-black">{rows.length}</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
@@ -273,7 +293,9 @@ function ClientConsentsPage() {
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Total Scopes Issued
</p>
<CardTitle className="text-2xl font-black">4,812</CardTitle>
<CardTitle className="text-2xl font-black">
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
@@ -281,7 +303,14 @@ function ClientConsentsPage() {
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Avg. Scopes per User
</p>
<CardTitle className="text-2xl font-black">3.8</CardTitle>
<CardTitle className="text-2xl font-black">
{rows.length > 0
? (
rows.reduce((acc, row) => acc + row.grantedScopes.length, 0) /
rows.length
).toFixed(1)
: "0.0"}
</CardTitle>
</CardHeader>
</Card>
</div>

View File

@@ -49,10 +49,14 @@ export type ClientUpsertRequest = {
export type ConsentSummary = {
subject: string;
userName?: string;
clientId: string;
clientName?: string;
grantedScopes: string[];
authenticatedAt?: string;
createdAt: string;
tenantId?: string;
tenantName?: string;
};
export type ConsentListResponse = {

View File

@@ -0,0 +1,74 @@
# [Flow] 사용자 동의 내역 조회 및 동기화 흐름
이 문서는 DevFront(개발자 포털)에서 클라이언트별 사용자 동의 내역을 조회하는 기능의 전체적인 데이터 흐름과 백엔드 로직을 설명합니다. 이 기능의 핵심은 Ory Hydra의 API 제약을 우회하고 멀티 테넌트 환경에서 데이터를 격리하기 위해 Baron SSO 자체 DB를 활용하는 것입니다.
## 1. 데이터 동기화 (자체 DB에 저장)
사용자의 동의 상태가 변경될 때마다 Baron SSO의 `client_consents` 테이블에 실시간으로 데이터가 동기화됩니다.
### 1.1. 사용자가 최초로 동의할 때
- **시작점**: 사용자가 로그인 후 동의 화면에서 "허용" 버튼을 클릭합니다.
- **파일**: `backend/internal/handler/auth_handler.go`
- **함수**: `AcceptConsentRequest`
- **로직**:
1. 프론트엔드로부터 `consent_challenge`와 사용자가 선택한 `scopes`를 전달받습니다.
2. `h.Hydra.AcceptConsentRequest`를 호출하여 Ory Hydra에 동의를 최종 승인합니다.
3. Hydra 요청이 성공하면, `domain.ClientConsent` 모델 객체를 생성합니다.
4. `h.ConsentRepo.Upsert` 함수를 호출하여 `client_consents` 테이블에 해당 동의 내역을 저장(INSERT 또는 UPDATE)합니다.
### 1.2. 사용자가 이미 동의하여 자동으로 승인될 때
- **시작점**: 이미 동의한 사용자가 다시 로그인을 시도하여 동의 화면이 생략(`skip: true`)될 때.
- **파일**: `backend/internal/handler/auth_handler.go`
- **함수**: `GetConsentRequest`
- **로직**:
1. Ory Hydra로부터 `skip: true` 응답을 받습니다.
2. 백엔드는 이 요청을 자동으로 수락하기 위해 `h.Hydra.AcceptConsentRequest`를 내부적으로 호출합니다.
3. 자동 승인이 성공하면, **마찬가지로 `h.ConsentRepo.Upsert`를 호출하여 `client_consents` 테이블의 데이터를 최신 상태로 동기화합니다.** 이는 동의 내역의 일관성을 보장합니다.
## 2. 데이터 조회 (DevFront 목록 표시)
관리자가 DevFront에서 동의 내역을 조회할 때의 흐름입니다.
- **시작점**: 관리자가 DevFront의 `Consent & Users` 페이지에 진입합니다.
- **파일**: `devfront/src/features/clients/ClientConsentsPage.tsx`
- **로직**:
1. React Query의 `useQuery` 훅이 실행되면서 백엔드 API `GET /api/v1/dev/consents?client_id=<ID>`를 호출합니다.
2. (보안) 이때 axios interceptor 등을 통해 현재 관리자의 **테넌트 ID가 담긴 `X-Tenant-ID` 헤더**를 함께 전송합니다.
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `ListConsents`
- **로직**:
1. `client_id``X-Tenant-ID` 헤더 값을 파라미터로 받습니다.
2. **테넌트 ID 유무에 따라 분기합니다**:
- `X-Tenant-ID`가 있으면, `h.ConsentRepo.ListByTenant`를 호출합니다.
- `X-Tenant-ID`가 없으면 (e.g., 슈퍼 관리자), `h.ConsentRepo.List`를 호출합니다.
- **파일**: `backend/internal/repository/client_consent_repository.go`
- **함수**: `ListByTenant`
- **로직**:
1. **가장 핵심적인 데이터 격리 로직이 실행됩니다.**
2. GORM을 사용하여 `client_consents` 테이블과 `users` 테이블을 `JOIN`합니다.
3. `WHERE` 절을 통해 `client_id`와 **관리자의 `tenant_id`**를 동시에 조건으로 사용하여, 해당 테넌트에 속한 사용자들의 동의 내역만 안전하게 필터링합니다.
4. 조회된 결과를 `DevHandler`로 반환합니다.
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `ListConsents` (계속)
- **로직**:
1. Repository로부터 필터링된 동의 목록을 전달받습니다.
2. 목록의 각 항목(사용자 `subject`)에 대해 `h.KratosAdmin.GetIdentity`를 호출하여 Kratos로부터 사용자 이름, 이메일 등 추가 정보를 가져와 응답 데이터를 보강합니다.
3. 최종적으로 보강된 데이터를 JSON 형태로 프론트엔드에 반환합니다.
- **파일**: `devfront/src/features/clients/ClientConsentsPage.tsx` (계속)
- **로직**:
1. `useQuery`가 성공적으로 데이터를 받아오면, 상태가 업데이트되고 화면에 테이블 형태로 동의 내역이 렌더링됩니다.
## 3. 데이터 철회 (동의 취소)
- **시작점**: 관리자가 DevFront의 동의 목록에서 "Revoke" 버튼을 클릭합니다.
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `RevokeConsents`
- **로직**:
1. `h.Hydra.RevokeConsentSessions`를 호출하여 Ory Hydra에서 실제 OIDC 세션을 무효화합니다.
2. 성공 시, `h.ConsentRepo.Delete`를 호출하여 `client_consents` 테이블에서도 해당 동의 내역을 삭제하여 상태를 일치시킵니다.