diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f5326946..11818e2c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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() diff --git a/backend/go.mod b/backend/go.mod index a7d03551..6e6549a8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 365e8e00..09f526f5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index ff8bef3a..fcd15586 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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 ) } diff --git a/backend/internal/domain/client_consent.go b/backend/internal/domain/client_consent.go new file mode 100644 index 00000000..edda1e6a --- /dev/null +++ b/backend/internal/domain/client_consent.go @@ -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 +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7359f48f..a38acc4b 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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, diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 732e4e2f..18d8347d 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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) } diff --git a/backend/internal/repository/client_consent_repository.go b/backend/internal/repository/client_consent_repository.go new file mode 100644 index 00000000..64a85e2a --- /dev/null +++ b/backend/internal/repository/client_consent_repository.go @@ -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 +} diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index d21349db..c0d949d4 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -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... )} +
- Showing 1 to{" "} - 4 of{" "} - 1,250 users + Showing {rows.length > 0 ? 1 : 0} to{" "} + {rows.length} of{" "} + {rows.length} users
Active Grants
-Total Scopes Issued
-Avg. Scopes per User
-