diff --git a/cmd/serve.go b/cmd/serve.go index 2a4786a46..38f0bf686 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -93,6 +93,7 @@ import ( "github.com/go-webauthn/webauthn/webauthn" "github.com/raystack/frontier/config" + "github.com/raystack/frontier/core/consent" "github.com/raystack/frontier/core/group" "github.com/raystack/frontier/core/membership" "github.com/raystack/frontier/core/namespace" @@ -343,6 +344,14 @@ func buildAPIDependencies( } preferenceService := preference.NewService(postgres.NewPreferenceRepository(dbc), traits) + // validated here so a deployment that asks for documents it cannot serve + // fails at boot instead of at someone's signup + if err := cfg.App.Consent.Validate(); err != nil { + return api.Deps{}, err + } + consentService := consent.NewService(cfg.App.Consent) + logConsentDocuments(logger, consentService.Documents()) + var tokenKeySet jwk.Set if len(cfg.App.Authentication.Token.RSAPath) > 0 { if ks, err := jwk.ReadFile(cfg.App.Authentication.Token.RSAPath); err != nil { @@ -628,6 +637,7 @@ func buildAPIDependencies( ResourceService: resourceService, SessionService: sessionService, AuthnService: authnService, + ConsentService: consentService, DeleterService: cascadeDeleter, MetaSchemaService: metaschemaService, BootstrapService: bootstrapService, @@ -668,6 +678,24 @@ func buildAPIDependencies( return dependencies, nil } +// logConsentDocuments records the set resolved at boot. Any field can be +// overridden through the environment, so this log, not the config repository, +// is what says which documents a deployment was actually serving. +func logConsentDocuments(logger *slog.Logger, documents []consent.Document) { + if len(documents) == 0 { + logger.Info("consent disabled, no documents required at signup") + return + } + logger.Info("consent enabled", "documents", len(documents)) + for _, document := range documents { + logger.Info("consent document", + "id", document.ID, + "title", document.Title, + "version", document.Version, + "url", document.URL) + } +} + // StripeTransport wraps the default http.RoundTripper to add metrics. type StripeTransport struct { Base http.RoundTripper diff --git a/config/sample.config.yaml b/config/sample.config.yaml index 5cffcf5f3..27465d00a 100644 --- a/config/sample.config.yaml +++ b/config/sample.config.yaml @@ -202,6 +202,35 @@ app: # this is used to validate the webhook payloads encryption_key: "hash-secret-should-be-32-chars--" + # documents a user has to accept before an account is created. + # frontier stores one consent record per signup, copying each document's + # version and url into it, and never reads what is behind the url. + consent: + # false (the default) keeps the behaviour frontier had before consent + # existed: ListConsentDocuments returns an empty list and no signup is + # gated. true with no documents fails at boot rather than doing nothing. + enabled: false + # keyed by document id. the key is what the client sends back as + # accepted_document_ids, and every document listed here is required at + # signup — there is no per-document "required" flag. + # version is opaque: it is compared for equality only, so dates, semver or + # commit SHAs all work, and it is a version bump, not the url, that makes a + # new document. config is read at boot, so changing any of this needs a + # restart. + documents: + terms_of_service: + title: "Terms & Conditions" + version: "2026-04-01" + url: "https://example.org/legal/terms/2026-04-01" + privacy_policy: + title: "Privacy Policy" + version: "2026-04-01" + url: "https://example.org/legal/privacy/2026-04-01" + eula: + title: "End User License Agreement" + version: "2026-02-14" + url: "https://example.org/legal/eula/2026-02-14" + # metaschema cache configuration metaschema: # how often each server reloads the metaschema cache from the database, so a diff --git a/core/consent/config.go b/core/consent/config.go new file mode 100644 index 000000000..1c4a83311 --- /dev/null +++ b/core/consent/config.go @@ -0,0 +1,74 @@ +package consent + +import ( + "fmt" + "net/url" + "sort" +) + +// Config lists the documents a deployment asks people to accept before an +// account is created. It sits at app.consent, beside app.authentication. +// +// Keyed by document id rather than a list, matching how authenticate.Config +// keys oidc_config: the key enforces unique ids and stays env-overridable. +// Every document is required at signup, so there is no per-document flag. +type Config struct { + // Enabled switches the whole feature off by default. + Enabled bool `yaml:"enabled" mapstructure:"enabled" default:"false"` + + Documents map[string]DocumentConfig `yaml:"documents" mapstructure:"documents"` +} + +type DocumentConfig struct { + Title string `yaml:"title" mapstructure:"title"` + // Version is copied into the consent record and compared for equality only. + Version string `yaml:"version" mapstructure:"version"` + URL string `yaml:"url" mapstructure:"url"` +} + +// Validate runs at boot, so bad config stops the server rather than surfacing +// on someone's signup. A disabled block is not checked at all: nothing reads it, +// so a half-written map is only an error once consent is turned on. +func (c Config) Validate() error { + if !c.Enabled { + return nil + } + + // failing here rather than silently disabling itself, which would look + // identical to a working deployment while asking nobody to accept anything + if len(c.Documents) == 0 { + return fmt.Errorf("app.consent is enabled but configures no documents") + } + + // sorted so the error names the same document on every boot + for _, id := range sortedIDs(c.Documents) { + doc := c.Documents[id] + if id == "" { + return fmt.Errorf("app.consent has a document with an empty id") + } + if doc.Version == "" { + return fmt.Errorf("app.consent document %q has an empty version", id) + } + if doc.URL == "" { + return fmt.Errorf("app.consent document %q has an empty url", id) + } + parsed, err := url.Parse(doc.URL) + if err != nil { + return fmt.Errorf("app.consent document %q has an unparseable url %q: %w", id, doc.URL, err) + } + // url.Parse accepts a bare path, which no client can link to + if !parsed.IsAbs() || parsed.Host == "" { + return fmt.Errorf("app.consent document %q needs an absolute url with a host, got %q", id, doc.URL) + } + } + return nil +} + +func sortedIDs(documents map[string]DocumentConfig) []string { + ids := make([]string, 0, len(documents)) + for id := range documents { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} diff --git a/core/consent/config_test.go b/core/consent/config_test.go new file mode 100644 index 000000000..ff88e5f13 --- /dev/null +++ b/core/consent/config_test.go @@ -0,0 +1,145 @@ +package consent_test + +import ( + "testing" + + "github.com/raystack/frontier/core/consent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfig_Validate(t *testing.T) { + t.Run("accepts a fully configured block", func(t *testing.T) { + require.NoError(t, enabledConfig().Validate()) + }) + + t.Run("accepts a disabled block", func(t *testing.T) { + require.NoError(t, consent.Config{}.Validate()) + }) + + t.Run("does not check a disabled block", func(t *testing.T) { + // a half-written documents map on a deployment that has not turned + // consent on yet is not an error; turning it on is what makes it one + config := consent.Config{ + Documents: map[string]consent.DocumentConfig{ + "terms_of_service": {}, + }, + } + + require.NoError(t, config.Validate()) + + config.Enabled = true + require.Error(t, config.Validate()) + }) + + t.Run("rejects enabled with no documents", func(t *testing.T) { + // silently disabling itself would look identical to a working + // deployment while asking nobody to accept anything + err := consent.Config{Enabled: true}.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "no documents") + }) + + t.Run("rejects an empty id", func(t *testing.T) { + config := consent.Config{ + Enabled: true, + Documents: map[string]consent.DocumentConfig{ + "": {Title: "Terms", Version: "1", URL: "https://example.org/terms"}, + }, + } + + err := config.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "empty id") + }) + + t.Run("rejects an empty version", func(t *testing.T) { + config := enabledConfig() + config.Documents["terms_of_service"] = consent.DocumentConfig{ + Title: "Terms & Conditions", + URL: "https://example.org/legal/terms", + } + + err := config.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "terms_of_service") + assert.Contains(t, err.Error(), "empty version") + }) + + t.Run("rejects an empty url", func(t *testing.T) { + config := enabledConfig() + config.Documents["privacy_policy"] = consent.DocumentConfig{ + Title: "Privacy Policy", + Version: "2026-04-01", + } + + err := config.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "privacy_policy") + assert.Contains(t, err.Error(), "empty url") + }) + + t.Run("rejects a url that does not parse", func(t *testing.T) { + config := enabledConfig() + config.Documents["eula"] = consent.DocumentConfig{ + Title: "End User License Agreement", + Version: "2026-02-14", + URL: "://example.org/legal/eula", + } + + err := config.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "eula") + }) + + t.Run("rejects a url the client cannot link to", func(t *testing.T) { + // url.Parse accepts a bare path, and a document nobody can open is as + // useless as one that does not parse at all + config := enabledConfig() + config.Documents["eula"] = consent.DocumentConfig{ + Title: "End User License Agreement", + Version: "2026-02-14", + URL: "example.org/legal/eula", + } + + err := config.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "absolute url") + }) + + t.Run("does not require a title", func(t *testing.T) { + // the RFC requires ids, versions and URLs to be non-empty, and stops + // there; an untitled document renders badly but serves correctly + config := enabledConfig() + config.Documents["eula"] = consent.DocumentConfig{ + Version: "2026-02-14", + URL: "https://example.org/legal/eula", + } + + require.NoError(t, config.Validate()) + }) + + t.Run("names the same document on every run", func(t *testing.T) { + // map iteration is randomised, so a validator that walks it unsorted + // reports a different document each boot + config := consent.Config{ + Enabled: true, + Documents: map[string]consent.DocumentConfig{ + "aaa_broken": {Version: "1"}, + "zzz_broken": {Version: "1"}, + }, + } + + for i := 0; i < 20; i++ { + err := config.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "aaa_broken") + } + }) +} diff --git a/core/consent/consent.go b/core/consent/consent.go new file mode 100644 index 000000000..7ef676adc --- /dev/null +++ b/core/consent/consent.go @@ -0,0 +1,11 @@ +package consent + +// Document is one document a user has to accept before an account is created. +// Version is opaque: compared for equality only, so dates, semver or SHAs all +// work. Frontier never reads what is behind URL. +type Document struct { + ID string + Title string + Version string + URL string +} diff --git a/core/consent/errors.go b/core/consent/errors.go new file mode 100644 index 000000000..c78043d73 --- /dev/null +++ b/core/consent/errors.go @@ -0,0 +1,13 @@ +package consent + +import "errors" + +var ( + // ErrUnknownDocuments is returned for an id this deployment does not + // configure, which is how a mismatch with the client's list surfaces. + ErrUnknownDocuments = errors.New("unknown consent document ids") + + // ErrMissingDocuments is returned when the ids do not cover every configured + // document. All of them are required at signup. + ErrMissingDocuments = errors.New("missing consent document ids") +) diff --git a/core/consent/service.go b/core/consent/service.go new file mode 100644 index 000000000..d8017e068 --- /dev/null +++ b/core/consent/service.go @@ -0,0 +1,120 @@ +package consent + +import ( + "fmt" + "sort" + "strings" +) + +// Service owns the document config, so it owns the checks that read it. Config +// is read at boot, so a version change needs a restart. +type Service struct { + config Config +} + +func NewService(config Config) *Service { + return &Service{ + config: config, + } +} + +// Documents returns every configured document, ordered by id so a response built +// from it is stable. Empty when disabled, so one client build works against both +// kinds of deployment. +func (s Service) Documents() []Document { + if !s.config.Enabled { + return nil + } + + ids := sortedIDs(s.config.Documents) + documents := make([]Document, 0, len(ids)) + for _, id := range ids { + documents = append(documents, s.document(id)) + } + return documents +} + +// Resolve maps ids to their config snapshots and rejects unknown ones, saying +// nothing about whether the set is complete — for callers where completeness is +// not yet knowable. Duplicates are removed. +// +// Disabled, it resolves nothing and rejects nothing, so the ids are ignored +// rather than refused. +func (s Service) Resolve(ids []string) ([]Document, error) { + if !s.config.Enabled { + return nil, nil + } + + accepted := uniqueSorted(ids) + var unknown []string + for _, id := range accepted { + if _, ok := s.config.Documents[id]; !ok { + unknown = append(unknown, id) + } + } + if len(unknown) > 0 { + return nil, fmt.Errorf("%w: %s", ErrUnknownDocuments, strings.Join(unknown, ", ")) + } + + documents := make([]Document, 0, len(accepted)) + for _, id := range accepted { + documents = append(documents, s.document(id)) + } + return documents, nil +} + +// ResolveAll is Resolve plus the completeness rule: the ids must cover every +// configured document, no more and no less. Both directions are compared, so the +// error names what is wrong rather than just that something is. +func (s Service) ResolveAll(ids []string) ([]Document, error) { + if !s.config.Enabled { + return nil, nil + } + + // the "no more" direction and the deduplication both come from Resolve + documents, err := s.Resolve(ids) + if err != nil { + return nil, err + } + + accepted := make(map[string]struct{}, len(documents)) + for _, document := range documents { + accepted[document.ID] = struct{}{} + } + + var missing []string + for _, id := range sortedIDs(s.config.Documents) { + if _, ok := accepted[id]; !ok { + missing = append(missing, id) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("%w: %s", ErrMissingDocuments, strings.Join(missing, ", ")) + } + return documents, nil +} + +func (s Service) document(id string) Document { + document := s.config.Documents[id] + return Document{ + ID: id, + Title: document.Title, + Version: document.Version, + URL: document.URL, + } +} + +// uniqueSorted so the same accepted set produces the same document list +// whatever order the client sent it in. +func uniqueSorted(ids []string) []string { + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + seen[id] = struct{}{} + } + unique := make([]string, 0, len(seen)) + for id := range seen { + unique = append(unique, id) + } + sort.Strings(unique) + return unique +} diff --git a/core/consent/service_test.go b/core/consent/service_test.go new file mode 100644 index 000000000..e7e56b59a --- /dev/null +++ b/core/consent/service_test.go @@ -0,0 +1,228 @@ +package consent_test + +import ( + "testing" + + "github.com/raystack/frontier/core/consent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// enabledConfig is the three-document set a deployment configures: terms, +// privacy policy and EULA. The ids are deliberately not in alphabetical order +// here, so the ordering the service applies is visible in every assertion. +func enabledConfig() consent.Config { + return consent.Config{ + Enabled: true, + Documents: map[string]consent.DocumentConfig{ + "terms_of_service": { + Title: "Terms & Conditions", + Version: "2026-04-01", + URL: "https://example.org/legal/terms/2026-04-01", + }, + "privacy_policy": { + Title: "Privacy Policy", + Version: "2026-04-01", + URL: "https://example.org/legal/privacy/2026-04-01", + }, + "eula": { + Title: "End User License Agreement", + Version: "2026-02-14", + URL: "https://example.org/legal/eula/2026-02-14", + }, + }, + } +} + +func allDocuments() []consent.Document { + return []consent.Document{ + { + ID: "eula", + Title: "End User License Agreement", + Version: "2026-02-14", + URL: "https://example.org/legal/eula/2026-02-14", + }, + { + ID: "privacy_policy", + Title: "Privacy Policy", + Version: "2026-04-01", + URL: "https://example.org/legal/privacy/2026-04-01", + }, + { + ID: "terms_of_service", + Title: "Terms & Conditions", + Version: "2026-04-01", + URL: "https://example.org/legal/terms/2026-04-01", + }, + } +} + +func TestService_Documents(t *testing.T) { + t.Run("returns every configured document ordered by id", func(t *testing.T) { + documents := consent.NewService(enabledConfig()).Documents() + + assert.Equal(t, allDocuments(), documents) + }) + + t.Run("orders by id whatever order config was read in", func(t *testing.T) { + // map iteration is randomised, so run it enough times that an + // unsorted implementation cannot pass by luck + service := consent.NewService(enabledConfig()) + for i := 0; i < 20; i++ { + assert.Equal(t, []string{"eula", "privacy_policy", "terms_of_service"}, ids(service.Documents())) + } + }) + + t.Run("returns empty when disabled, even with documents configured", func(t *testing.T) { + config := enabledConfig() + config.Enabled = false + + assert.Empty(t, consent.NewService(config).Documents()) + }) + + t.Run("returns empty for the zero config", func(t *testing.T) { + assert.Empty(t, consent.NewService(consent.Config{}).Documents()) + }) +} + +func TestService_Resolve(t *testing.T) { + service := consent.NewService(enabledConfig()) + + t.Run("maps known ids to their config snapshots", func(t *testing.T) { + documents, err := service.Resolve([]string{"terms_of_service"}) + + require.NoError(t, err) + assert.Equal(t, []consent.Document{{ + ID: "terms_of_service", + Title: "Terms & Conditions", + Version: "2026-04-01", + URL: "https://example.org/legal/terms/2026-04-01", + }}, documents) + }) + + t.Run("says nothing about completeness", func(t *testing.T) { + // two of three, which ResolveAll would reject + documents, err := service.Resolve([]string{"privacy_policy", "eula"}) + + require.NoError(t, err) + assert.Equal(t, []string{"eula", "privacy_policy"}, ids(documents)) + }) + + t.Run("orders the result by id", func(t *testing.T) { + documents, err := service.Resolve([]string{"terms_of_service", "eula", "privacy_policy"}) + + require.NoError(t, err) + assert.Equal(t, allDocuments(), documents) + }) + + t.Run("removes duplicates", func(t *testing.T) { + documents, err := service.Resolve([]string{"eula", "eula", "eula"}) + + require.NoError(t, err) + assert.Equal(t, []string{"eula"}, ids(documents)) + }) + + t.Run("rejects an unknown id and names it", func(t *testing.T) { + _, err := service.Resolve([]string{"terms_of_service", "cookie_policy"}) + + require.ErrorIs(t, err, consent.ErrUnknownDocuments) + assert.Contains(t, err.Error(), "cookie_policy") + assert.NotContains(t, err.Error(), "terms_of_service") + }) + + t.Run("names every unknown id", func(t *testing.T) { + _, err := service.Resolve([]string{"cookie_policy", "acceptable_use"}) + + require.ErrorIs(t, err, consent.ErrUnknownDocuments) + assert.Contains(t, err.Error(), "cookie_policy") + assert.Contains(t, err.Error(), "acceptable_use") + }) + + t.Run("resolves nothing for no ids", func(t *testing.T) { + documents, err := service.Resolve(nil) + + require.NoError(t, err) + assert.Empty(t, documents) + }) + + t.Run("ignores ids when disabled rather than rejecting them", func(t *testing.T) { + config := enabledConfig() + config.Enabled = false + + documents, err := consent.NewService(config).Resolve([]string{"anything_at_all"}) + + require.NoError(t, err) + assert.Empty(t, documents) + }) +} + +func TestService_ResolveAll(t *testing.T) { + service := consent.NewService(enabledConfig()) + + t.Run("accepts a set covering every configured document", func(t *testing.T) { + documents, err := service.ResolveAll([]string{"privacy_policy", "terms_of_service", "eula"}) + + require.NoError(t, err) + assert.Equal(t, allDocuments(), documents) + }) + + t.Run("removes duplicates before the check", func(t *testing.T) { + documents, err := service.ResolveAll([]string{"eula", "privacy_policy", "eula", "terms_of_service", "eula"}) + + require.NoError(t, err) + assert.Equal(t, allDocuments(), documents) + }) + + t.Run("rejects an incomplete set and names what is missing", func(t *testing.T) { + _, err := service.ResolveAll([]string{"terms_of_service"}) + + require.ErrorIs(t, err, consent.ErrMissingDocuments) + assert.Contains(t, err.Error(), "eula") + assert.Contains(t, err.Error(), "privacy_policy") + assert.NotContains(t, err.Error(), "terms_of_service") + }) + + t.Run("rejects an empty set when documents are configured", func(t *testing.T) { + _, err := service.ResolveAll(nil) + + require.ErrorIs(t, err, consent.ErrMissingDocuments) + }) + + t.Run("rejects an id config does not know and names it", func(t *testing.T) { + _, err := service.ResolveAll([]string{"terms_of_service", "privacy_policy", "eula", "cookie_policy"}) + + require.ErrorIs(t, err, consent.ErrUnknownDocuments) + assert.Contains(t, err.Error(), "cookie_policy") + }) + + t.Run("reports the unknown id when the set is both incomplete and unknown", func(t *testing.T) { + // an id config does not know is a client or config mismatch, and it is + // the more diagnostic of the two, so it is reported first + _, err := service.ResolveAll([]string{"cookie_policy"}) + + require.ErrorIs(t, err, consent.ErrUnknownDocuments) + assert.NotErrorIs(t, err, consent.ErrMissingDocuments) + }) + + t.Run("ignores ids when disabled rather than rejecting them", func(t *testing.T) { + config := enabledConfig() + config.Enabled = false + service := consent.NewService(config) + + documents, err := service.ResolveAll(nil) + require.NoError(t, err) + assert.Empty(t, documents) + + documents, err = service.ResolveAll([]string{"anything_at_all"}) + require.NoError(t, err) + assert.Empty(t, documents) + }) +} + +func ids(documents []consent.Document) []string { + out := make([]string, 0, len(documents)) + for _, document := range documents { + out = append(out, document.ID) + } + return out +} diff --git a/docs/content/docs/reference/configurations.mdx b/docs/content/docs/reference/configurations.mdx index 0a08afbbc..0f82f6bef 100644 --- a/docs/content/docs/reference/configurations.mdx +++ b/docs/content/docs/reference/configurations.mdx @@ -143,6 +143,28 @@ app: # encryption key used to encrypt the secrets stored in database not to encrypt # the webhook payload encryption_key: "encryption-key-should-be-32-chars--" + # documents a user has to accept before an account is created + consent: + # false (the default) keeps the behaviour frontier had before consent + # existed: ListConsentDocuments returns an empty list and no signup is + # gated. true with no documents fails at boot rather than doing nothing. + enabled: false + # keyed by document id. every document listed here is required at signup, + # and the key is what the client sends back as accepted_document_ids. + # version is opaque: it is compared for equality only, never parsed. + documents: + terms_of_service: + title: "Terms & Conditions" + version: "2026-04-01" + url: "https://example.org/legal/terms/2026-04-01" + privacy_policy: + title: "Privacy Policy" + version: "2026-04-01" + url: "https://example.org/legal/privacy/2026-04-01" + eula: + title: "End User License Agreement" + version: "2026-02-14" + url: "https://example.org/legal/eula/2026-02-14" # metaschema cache configuration metaschema: # how often each server reloads the metaschema cache from the database, so a @@ -270,6 +292,19 @@ Configuration to allow authentication in Frontier. | **app.authentication.oidc_config.google.client_secret** | Google client secret for OIDC authentication. | No | "xxxxx" | | **app.authentication.oidc_config.google.issuer_url** | Google issuer URL for OIDC authentication. | No | "https://accounts.google.com" | +### Consent Configurations + +Documents a user has to accept before an account is created. Frontier stores one consent record per signup, copying each document's version and URL into it, and never reads what is behind the URL. + +Every document in the map is required at signup, so there is no per-document `required` flag. The map key is the document id, which is what a client sends back as `accepted_document_ids` — the key also enforces unique ids, and keeps a single field env-overridable. Config is read at boot, so any change here needs a restart, and the resolved set is logged at startup so a log, rather than the config repository, says what a deployment was serving. + +| **Field** | **Description** | **Example** | **Required** | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | ------------------------ | +| **app.consent.enabled** | Switches the whole feature. Disabled, `ListConsentDocuments` returns an empty list and no signup is gated. Enabled with no documents fails at boot. | `false` | No (default: `false`) | +| **app.consent.documents.\.title** | Human readable name, used by the client to label the link. | `"Terms & Conditions"` | No | +| **app.consent.documents.\.version** | Opaque version, copied into the consent record and compared for equality only. Dates, semver or commit SHAs all work. | `"2026-04-01"` | Yes (when enabled) | +| **app.consent.documents.\.url** | Absolute URL of the document. Frontier never reads what is behind it. | `"https://example.org/legal/terms"` | Yes (when enabled) | + ### Admin Configurations | **Field** | **Description** | **Example** | **Required** | diff --git a/internal/api/api.go b/internal/api/api.go index aaee3ca31..b04a07c2d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -25,6 +25,7 @@ import ( "github.com/raystack/frontier/core/auditrecord" "github.com/raystack/frontier/core/authenticate" "github.com/raystack/frontier/core/authenticate/session" + "github.com/raystack/frontier/core/consent" "github.com/raystack/frontier/core/deleter" "github.com/raystack/frontier/core/domain" "github.com/raystack/frontier/core/event" @@ -64,6 +65,7 @@ type Deps struct { ResourceService *resource.Service SessionService *session.Service AuthnService *authenticate.Service + ConsentService *consent.Service DeleterService *deleter.Service MetaSchemaService *metaschema.Service BootstrapService *bootstrap.Service diff --git a/internal/api/v1beta1connect/consent.go b/internal/api/v1beta1connect/consent.go new file mode 100644 index 000000000..b48e3f3ba --- /dev/null +++ b/internal/api/v1beta1connect/consent.go @@ -0,0 +1,24 @@ +package v1beta1connect + +import ( + "context" + + "connectrpc.com/connect" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" +) + +// ListConsentDocuments returns the documents this deployment asks a user to +// accept before an account is created. Unauthenticated, because the ids are an +// input to an unauthenticated Authenticate. Empty when app.consent is disabled. +func (h *ConnectHandler) ListConsentDocuments(ctx context.Context, request *connect.Request[frontierv1beta1.ListConsentDocumentsRequest]) (*connect.Response[frontierv1beta1.ListConsentDocumentsResponse], error) { + var pbdocuments []*frontierv1beta1.ConsentDocument + for _, document := range h.consentService.Documents() { + pbdocuments = append(pbdocuments, &frontierv1beta1.ConsentDocument{ + Id: document.ID, + Title: document.Title, + Version: document.Version, + Url: document.URL, + }) + } + return connect.NewResponse(&frontierv1beta1.ListConsentDocumentsResponse{Documents: pbdocuments}), nil +} diff --git a/internal/api/v1beta1connect/consent_test.go b/internal/api/v1beta1connect/consent_test.go new file mode 100644 index 000000000..2dd42ecf1 --- /dev/null +++ b/internal/api/v1beta1connect/consent_test.go @@ -0,0 +1,125 @@ +package v1beta1connect + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/consent" + "github.com/raystack/frontier/internal/api/v1beta1connect/mocks" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnectHandler_ListConsentDocuments(t *testing.T) { + tests := []struct { + name string + setup func(consentSrv *mocks.ConsentService) + want []*frontierv1beta1.ConsentDocument + }{ + { + name: "should return every configured document with all four fields", + setup: func(consentSrv *mocks.ConsentService) { + consentSrv.EXPECT().Documents().Return([]consent.Document{ + { + ID: "eula", + Title: "End User License Agreement", + Version: "2026-02-14", + URL: "https://example.org/legal/eula/2026-02-14", + }, + { + ID: "privacy_policy", + Title: "Privacy Policy", + Version: "2026-04-01", + URL: "https://example.org/legal/privacy/2026-04-01", + }, + { + ID: "terms_of_service", + Title: "Terms & Conditions", + Version: "2026-04-01", + URL: "https://example.org/legal/terms/2026-04-01", + }, + }) + }, + want: []*frontierv1beta1.ConsentDocument{ + { + Id: "eula", + Title: "End User License Agreement", + Version: "2026-02-14", + Url: "https://example.org/legal/eula/2026-02-14", + }, + { + Id: "privacy_policy", + Title: "Privacy Policy", + Version: "2026-04-01", + Url: "https://example.org/legal/privacy/2026-04-01", + }, + { + Id: "terms_of_service", + Title: "Terms & Conditions", + Version: "2026-04-01", + Url: "https://example.org/legal/terms/2026-04-01", + }, + }, + }, + { + // no documents means no checkbox, so one client build works + // against a deployment that asks for consent and one that does not + name: "should return an empty list and no error when consent is disabled", + setup: func(consentSrv *mocks.ConsentService) { + consentSrv.EXPECT().Documents().Return(nil) + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockConsentSrv := new(mocks.ConsentService) + if tt.setup != nil { + tt.setup(mockConsentSrv) + } + + handler := &ConnectHandler{consentService: mockConsentSrv} + + resp, err := handler.ListConsentDocuments(context.Background(), + connect.NewRequest(&frontierv1beta1.ListConsentDocumentsRequest{})) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Len(t, resp.Msg.GetDocuments(), len(tt.want)) + for i, want := range tt.want { + got := resp.Msg.GetDocuments()[i] + assert.Equal(t, want.GetId(), got.GetId()) + assert.Equal(t, want.GetTitle(), got.GetTitle()) + assert.Equal(t, want.GetVersion(), got.GetVersion()) + assert.Equal(t, want.GetUrl(), got.GetUrl()) + } + mockConsentSrv.AssertExpectations(t) + }) + } +} + +// TestConnectHandler_ListConsentDocuments_OrderIsTheServiceOrder pins that the +// handler passes the service's ordering through untouched. The service orders +// by id, which is what makes the response stable across restarts. +func TestConnectHandler_ListConsentDocuments_OrderIsTheServiceOrder(t *testing.T) { + mockConsentSrv := new(mocks.ConsentService) + mockConsentSrv.EXPECT().Documents().Return([]consent.Document{ + {ID: "eula", Title: "EULA", Version: "1", URL: "https://example.org/eula"}, + {ID: "privacy_policy", Title: "Privacy", Version: "1", URL: "https://example.org/privacy"}, + {ID: "terms_of_service", Title: "Terms", Version: "1", URL: "https://example.org/terms"}, + }) + + handler := &ConnectHandler{consentService: mockConsentSrv} + resp, err := handler.ListConsentDocuments(context.Background(), + connect.NewRequest(&frontierv1beta1.ListConsentDocumentsRequest{})) + + require.NoError(t, err) + ids := make([]string, 0, len(resp.Msg.GetDocuments())) + for _, document := range resp.Msg.GetDocuments() { + ids = append(ids, document.GetId()) + } + assert.Equal(t, []string{"eula", "privacy_policy", "terms_of_service"}, ids) +} diff --git a/internal/api/v1beta1connect/interfaces.go b/internal/api/v1beta1connect/interfaces.go index 542bf5ec9..ba605a25a 100644 --- a/internal/api/v1beta1connect/interfaces.go +++ b/internal/api/v1beta1connect/interfaces.go @@ -29,6 +29,7 @@ import ( "github.com/raystack/frontier/core/auditrecord" "github.com/raystack/frontier/core/authenticate" frontiersession "github.com/raystack/frontier/core/authenticate/session" + "github.com/raystack/frontier/core/consent" "github.com/raystack/frontier/core/deleter" "github.com/raystack/frontier/core/domain" "github.com/raystack/frontier/core/event" @@ -369,6 +370,11 @@ type AuthnService interface { SanitizeCallbackURL(url string) string } +// ConsentService serves the documents a user must accept at signup, from config. +type ConsentService interface { + Documents() []consent.Document +} + type SessionService interface { ExtractFromContext(ctx context.Context) (*frontiersession.Session, error) Create(ctx context.Context, userID string, metadata frontiersession.SessionMetadata) (*frontiersession.Session, error) diff --git a/internal/api/v1beta1connect/mocks/consent_service.go b/internal/api/v1beta1connect/mocks/consent_service.go new file mode 100644 index 000000000..a3646d4d2 --- /dev/null +++ b/internal/api/v1beta1connect/mocks/consent_service.go @@ -0,0 +1,82 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + consent "github.com/raystack/frontier/core/consent" + mock "github.com/stretchr/testify/mock" +) + +// ConsentService is an autogenerated mock type for the ConsentService type +type ConsentService struct { + mock.Mock +} + +type ConsentService_Expecter struct { + mock *mock.Mock +} + +func (_m *ConsentService) EXPECT() *ConsentService_Expecter { + return &ConsentService_Expecter{mock: &_m.Mock} +} + +// Documents provides a mock function with no fields +func (_m *ConsentService) Documents() []consent.Document { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Documents") + } + + var r0 []consent.Document + if rf, ok := ret.Get(0).(func() []consent.Document); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]consent.Document) + } + } + + return r0 +} + +// ConsentService_Documents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Documents' +type ConsentService_Documents_Call struct { + *mock.Call +} + +// Documents is a helper method to define mock.On call +func (_e *ConsentService_Expecter) Documents() *ConsentService_Documents_Call { + return &ConsentService_Documents_Call{Call: _e.mock.On("Documents")} +} + +func (_c *ConsentService_Documents_Call) Run(run func()) *ConsentService_Documents_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ConsentService_Documents_Call) Return(_a0 []consent.Document) *ConsentService_Documents_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ConsentService_Documents_Call) RunAndReturn(run func() []consent.Document) *ConsentService_Documents_Call { + _c.Call.Return(run) + return _c +} + +// NewConsentService creates a new instance of ConsentService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewConsentService(t interface { + mock.TestingT + Cleanup(func()) +}) *ConsentService { + mock := &ConsentService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/api/v1beta1connect/v1beta1connect.go b/internal/api/v1beta1connect/v1beta1connect.go index 107ce0b29..17016d5b2 100644 --- a/internal/api/v1beta1connect/v1beta1connect.go +++ b/internal/api/v1beta1connect/v1beta1connect.go @@ -32,6 +32,7 @@ type ConnectHandler struct { resourceService ResourceService sessionService SessionService authnService AuthnService + consentService ConsentService deleterService CascadeDeleter metaSchemaService MetaSchemaService bootstrapService BootstrapService @@ -83,6 +84,7 @@ func NewConnectHandler(deps api.Deps, authConf authenticate.Config) *ConnectHand resourceService: deps.ResourceService, sessionService: deps.SessionService, authnService: deps.AuthnService, + consentService: deps.ConsentService, deleterService: deps.DeleterService, metaSchemaService: deps.MetaSchemaService, bootstrapService: deps.BootstrapService, diff --git a/pkg/server/config.go b/pkg/server/config.go index 0a0128797..d3cf66687 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -3,6 +3,7 @@ package server import ( "time" + "github.com/raystack/frontier/core/consent" "github.com/raystack/frontier/core/metaschema" "github.com/raystack/frontier/core/userpat" "github.com/raystack/frontier/core/webhook" @@ -94,6 +95,9 @@ type Config struct { Webhook webhook.Config `yaml:"webhook" mapstructure:"webhook"` PAT userpat.Config `yaml:"pat" mapstructure:"pat"` + // Consent lists the documents a user must accept at signup. Disabled by default. + Consent consent.Config `yaml:"consent" mapstructure:"consent"` + Metaschema metaschema.Config `yaml:"metaschema" mapstructure:"metaschema"` // AdditionalTraitsPath is a file path to a YAML file containing additional preference traits diff --git a/pkg/server/connect_interceptors/authentication.go b/pkg/server/connect_interceptors/authentication.go index 991f97002..15650f26a 100644 --- a/pkg/server/connect_interceptors/authentication.go +++ b/pkg/server/connect_interceptors/authentication.go @@ -102,6 +102,7 @@ func (i *AuthenticationInterceptor) WrapStreamingHandler(next connect.StreamingH // authenticationSkipList stores path to skip authentication, by default its enabled for all requests var authenticationSkipList = map[string]bool{ "/raystack.frontier.v1beta1.FrontierService/ListAuthStrategies": true, + "/raystack.frontier.v1beta1.FrontierService/ListConsentDocuments": true, "/raystack.frontier.v1beta1.FrontierService/Authenticate": true, "/raystack.frontier.v1beta1.FrontierService/AuthCallback": true, "/raystack.frontier.v1beta1.FrontierService/ListMetaSchemas": true, diff --git a/pkg/server/connect_interceptors/authorization.go b/pkg/server/connect_interceptors/authorization.go index 95671dcd1..49c22040c 100644 --- a/pkg/server/connect_interceptors/authorization.go +++ b/pkg/server/connect_interceptors/authorization.go @@ -94,6 +94,7 @@ func NewAuthorizationInterceptor(h *v1beta1connect.ConnectHandler) *Authorizatio var authorizationSkipEndpoints = map[string]bool{ "/raystack.frontier.v1beta1.FrontierService/GetJWKs": true, "/raystack.frontier.v1beta1.FrontierService/ListAuthStrategies": true, + "/raystack.frontier.v1beta1.FrontierService/ListConsentDocuments": true, "/raystack.frontier.v1beta1.FrontierService/Authenticate": true, "/raystack.frontier.v1beta1.FrontierService/AuthCallback": true, "/raystack.frontier.v1beta1.FrontierService/AuthToken": true,