Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -628,6 +637,7 @@ func buildAPIDependencies(
ResourceService: resourceService,
SessionService: sessionService,
AuthnService: authnService,
ConsentService: consentService,
DeleterService: cascadeDeleter,
MetaSchemaService: metaschemaService,
BootstrapService: bootstrapService,
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions core/consent/config.go
Original file line number Diff line number Diff line change
@@ -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
}
145 changes: 145 additions & 0 deletions core/consent/config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
11 changes: 11 additions & 0 deletions core/consent/consent.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions core/consent/errors.go
Original file line number Diff line number Diff line change
@@ -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")
)
Loading
Loading