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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
33
backend/internal/domain/client_consent.go
Normal file
33
backend/internal/domain/client_consent.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
92
backend/internal/repository/client_consent_repository.go
Normal file
92
backend/internal/repository/client_consent_repository.go
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
74
docs/consent_listing_flow.md
Normal file
74
docs/consent_listing_flow.md
Normal 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` 테이블에서도 해당 동의 내역을 삭제하여 상태를 일치시킵니다.
|
||||
Reference in New Issue
Block a user