From 3f85f6cfe374071783b5b27635251826c27597ee Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 24 Apr 2026 17:59:54 +0900 Subject: [PATCH] =?UTF-8?q?RP=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=95=EC=B1=85=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20consent=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 3 + .../handler/auth_handler_consent_test.go | 131 ++++++++++++++ .../internal/handler/client_tenant_access.go | 96 +++++++++++ .../handler/client_tenant_access_test.go | 16 ++ backend/internal/handler/common_test.go | 27 +++ backend/internal/handler/dev_handler.go | 47 +++++ backend/internal/handler/dev_handler_test.go | 161 ++++++++++++++++++ .../repository/client_consent_repository.go | 19 +++ 8 files changed, 500 insertions(+) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f8c294cc..2de7dbca 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -5120,6 +5120,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { slog.Error("failed to get hydra consent request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") } + consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope) // [DEBUG] Hydra 응답 상세 로깅 slog.Info("GetConsentRequest Debug", @@ -5323,6 +5324,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { slog.Error("failed to get hydra consent request before accepting", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") } + consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope) // 2. 스코프 필터링 (사용자가 선택한 것만 허용) if len(req.GrantScope) > 0 { @@ -5339,6 +5341,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { } consentRequest.RequestedScope = filteredScopes } + consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope) profile, err := h.resolveCurrentProfile(c) if err == nil && profile != nil { diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 305a38bb..b4eee08b 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -69,6 +69,59 @@ func TestGetConsentRequest_Normal(t *testing.T) { 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]interface{}{ + "challenge": "challenge-tenant-scope", + "requested_scope": []string{"openid", "profile"}, + "skip": false, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + "client_name": "Test App", + "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}, + }, + }, + }, + }), 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-tenant-scope", nil) + resp, err := app.Test(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + + assert.Equal(t, []interface{}{"openid", "tenant", "profile"}, body["requested_scope"]) + scopeDetails := body["scope_details"].(map[string]interface{}) + tenantDetail := scopeDetails["tenant"].(map[string]interface{}) + 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 @@ -202,3 +255,81 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { assert.Equal(t, 1, len(auditRepo.logs)) } + +func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { + 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]interface{}{ + "challenge": "challenge-tenant-accept", + "requested_scope": []string{"openid", "profile"}, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + "metadata": map[string]any{ + "tenant_id": "tenant-abc", + "tenant_access_restricted": true, + "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]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "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"].([]interface{}) { + capturedGrantScopes = append(capturedGrantScopes, scope.(string)) + } + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "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]interface{}{ + "email": "user@test.com", + }, + }, nil) + + app := newConsentTestApp(h) + + body, _ := json.Marshal(map[string]interface{}{ + "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") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes) +} diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index d58e2d36..a5bd08d6 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -2,6 +2,7 @@ package handler import ( "baron-sso-backend/internal/domain" + "encoding/json" "errors" "sort" "strings" @@ -142,3 +143,98 @@ func isClientTenantAccessAllowed(profile *domain.UserProfileResponse, client dom } return clientTenantAccessAllowed(profile, client) } + +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) +} diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index 51138845..5d065e5f 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -118,6 +118,22 @@ func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) { 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 { diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 1ff6fbc2..af462748 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -155,6 +155,22 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([] 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 { @@ -167,6 +183,17 @@ func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (* func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error { 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 { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index cc18a86c..814e498b 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -618,6 +618,47 @@ func isProtectedSystemClientID(clientID string) bool { return ok } +func tenantAccessPolicyChanged(before, after map[string]interface{}) bool { + if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) { + return true + } + + beforeAllowed := clientAllowedTenants(before) + afterAllowed := clientAllowedTenants(after) + if len(beforeAllowed) != len(afterAllowed) { + return true + } + for i := range beforeAllowed { + if beforeAllowed[i] != afterAllowed[i] { + return true + } + } + return false +} + +func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error { + if h.ConsentRepo == nil || h.Hydra == nil { + return nil + } + + subjects, err := h.ConsentRepo.ListSubjectsByClient(ctx, clientID) + if err != nil { + return err + } + + for _, subject := range subjects { + subject = strings.TrimSpace(subject) + if subject == "" { + continue + } + if err := h.Hydra.RevokeConsentSessions(ctx, subject, clientID); err != nil { + return err + } + } + + return h.ConsentRepo.DeleteByClient(ctx, clientID) +} + func isProtectedSystemClient(client domain.HydraClient) bool { return isProtectedSystemClientID(client.ClientID) } @@ -1767,6 +1808,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } + tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata) h.setAuditDetailsExtra(c, map[string]any{ "action": "UPDATE_CLIENT", @@ -1788,6 +1830,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if tenantPolicyChanged { + if err := h.revokeClientConsentsForPolicyChange(c.Context(), clientID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to revoke existing consents after tenant policy update: "+err.Error()) + } + } h.syncHeadlessJWKSCache(c.Context(), *updatedClient, "client_update") if updatedClient.ClientSecret != "" { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 8bbc2b50..293c0342 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1662,6 +1662,167 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) { assert.False(t, hasRequestObjectAlg) } +func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.T) { + var revokedSubjects []string + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, domain.HydraClient{ + ClientID: "client-1", + ClientName: "Tenant Guarded App", + RedirectURIs: []string{"https://rp.example.com/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + Scope: "openid tenant profile email", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "tenant_access_restricted": true, + "allowed_tenants": []string{"tenant-a"}, + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { + var updated domain.HydraClient + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.NoError(t, json.Unmarshal(body, &updated)) + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": updated.ClientID, + "client_name": updated.ClientName, + "redirect_uris": updated.RedirectURIs, + "grant_types": updated.GrantTypes, + "response_types": updated.ResponseTypes, + "scope": updated.Scope, + "token_endpoint_auth_method": updated.TokenEndpointAuthMethod, + "metadata": updated.Metadata, + }), nil + } + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + revokedSubjects = append(revokedSubjects, r.URL.Query().Get("subject")) + assert.Equal(t, "client-1", r.URL.Query().Get("client")) + return httpResponse(r, http.StatusNoContent, ""), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + {ClientID: "client-1", Subject: "user-1"}, + {ClientID: "client-1", Subject: "user-2"}, + {ClientID: "other-client", Subject: "user-3"}, + }, + } + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: transport}, + }, + ConsentRepo: consentRepo, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "metadata": map[string]any{ + "tenant_access_restricted": true, + "allowed_tenants": []string{"tenant-b"}, + }, + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.ElementsMatch(t, []string{"user-1", "user-2"}, revokedSubjects) + assert.Len(t, consentRepo.consents, 1) + assert.Equal(t, "other-client", consentRepo.consents[0].ClientID) +} + +func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.T) { + revoked := false + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, domain.HydraClient{ + ClientID: "client-1", + ClientName: "Tenant Guarded App", + RedirectURIs: []string{"https://rp.example.com/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + Scope: "openid tenant profile email", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "tenant_access_restricted": true, + "allowed_tenants": []string{"tenant-a"}, + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { + var updated domain.HydraClient + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.NoError(t, json.Unmarshal(body, &updated)) + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": updated.ClientID, + "client_name": updated.ClientName, + "redirect_uris": updated.RedirectURIs, + "grant_types": updated.GrantTypes, + "response_types": updated.ResponseTypes, + "scope": updated.Scope, + "token_endpoint_auth_method": updated.TokenEndpointAuthMethod, + "metadata": updated.Metadata, + }), nil + } + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + revoked = true + return httpResponse(r, http.StatusNoContent, ""), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + {ClientID: "client-1", Subject: "user-1"}, + }, + } + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: transport}, + }, + ConsentRepo: consentRepo, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Renamed App", + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.False(t, revoked) + assert.Len(t, consentRepo.consents, 1) +} + func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) _ = privateKey diff --git a/backend/internal/repository/client_consent_repository.go b/backend/internal/repository/client_consent_repository.go index 5e38742d..0c5f88c2 100644 --- a/backend/internal/repository/client_consent_repository.go +++ b/backend/internal/repository/client_consent_repository.go @@ -11,9 +11,11 @@ import ( type ClientConsentRepository interface { Upsert(ctx context.Context, consent *domain.ClientConsent) error Delete(ctx context.Context, subject, clientID string) error + DeleteByClient(ctx context.Context, 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) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) + ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) } @@ -56,6 +58,12 @@ func (r *clientConsentRepo) Delete(ctx context.Context, subject, clientID string Delete(&domain.ClientConsent{}).Error } +func (r *clientConsentRepo) DeleteByClient(ctx context.Context, clientID string) error { + return r.db.WithContext(ctx). + Where("client_id = ?", 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 @@ -117,3 +125,14 @@ func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ( Find(&consents).Error return consents, err } + +func (r *clientConsentRepo) ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) { + var subjects []string + err := r.db.WithContext(ctx).Unscoped(). + Model(&domain.ClientConsent{}). + Distinct("subject"). + Where("client_id = ?", clientID). + Order("subject ASC"). + Pluck("subject", &subjects).Error + return subjects, err +}