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/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 +}