Skip to content

Commit d1ded9a

Browse files
feat(consent): add app.consent config, the consent service, and ListConsentDocuments
Config is the source of truth for what a deployment asks people to accept, and an endpoint is how a client learns it. No database, no writes, and frontier never reads a document or parses a version string. app.consent is a map keyed by document id, beside app.authentication and app.pat. A map because it matches authenticate.Config keying oidc_config by strategy name, the key enforces unique ids, and a single field stays env-overridable. Every document in it is required at signup, so there is no per-document required flag: an optional document would need withdrawal, which is out of scope. Boot validation rejects empty ids, versions and URLs, URLs that do not parse or that a client cannot link to, and an enabled block with no documents, which would otherwise look identical to a working deployment while asking nobody to accept anything. The resolved set is logged at boot, because an env override cannot alter an existing record but it can produce wrong new ones, and that log rather than the config repo is what says what a deployment was serving. The service owns the config, so it owns the checks. Documents orders by id. Resolve maps ids to their config snapshots and rejects ids config does not know, saying nothing about completeness. ResolveAll adds the completeness rule and compares both sets in both directions, so the error names what is wrong. Disabled, all three are empty and no id is rejected, so one client build works against both kinds of deployment. ListConsentDocuments mirrors ListAuthStrategies and is unauthenticated on purpose: the URLs are meant to be read by anyone considering an account, and the ids are an input to an unauthenticated Authenticate, so requiring a session to learn what to accept before the account exists is a cycle. It joins both the authentication and the authorization skip lists, since an endpoint missing from the second is denied by default. The transactional write and the SDK follow separately. Refs docs/rfcs/0002-explicit-consent-at-signup.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VW3nysiE4H83VQk6BroMYc
1 parent 1cb69f7 commit d1ded9a

18 files changed

Lines changed: 972 additions & 0 deletions

File tree

cmd/serve.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import (
9393

9494
"github.com/go-webauthn/webauthn/webauthn"
9595
"github.com/raystack/frontier/config"
96+
"github.com/raystack/frontier/core/consent"
9697
"github.com/raystack/frontier/core/group"
9798
"github.com/raystack/frontier/core/membership"
9899
"github.com/raystack/frontier/core/namespace"
@@ -343,6 +344,14 @@ func buildAPIDependencies(
343344
}
344345
preferenceService := preference.NewService(postgres.NewPreferenceRepository(dbc), traits)
345346

347+
// consent config is validated here so a deployment that asks for documents
348+
// it cannot serve fails at boot instead of at someone's signup
349+
if err := cfg.App.Consent.Validate(); err != nil {
350+
return api.Deps{}, err
351+
}
352+
consentService := consent.NewService(cfg.App.Consent)
353+
logConsentDocuments(logger, consentService.Documents())
354+
346355
var tokenKeySet jwk.Set
347356
if len(cfg.App.Authentication.Token.RSAPath) > 0 {
348357
if ks, err := jwk.ReadFile(cfg.App.Authentication.Token.RSAPath); err != nil {
@@ -628,6 +637,7 @@ func buildAPIDependencies(
628637
ResourceService: resourceService,
629638
SessionService: sessionService,
630639
AuthnService: authnService,
640+
ConsentService: consentService,
631641
DeleterService: cascadeDeleter,
632642
MetaSchemaService: metaschemaService,
633643
BootstrapService: bootstrapService,
@@ -668,6 +678,26 @@ func buildAPIDependencies(
668678
return dependencies, nil
669679
}
670680

681+
// logConsentDocuments records the document set this deployment resolved at
682+
// boot. Any single field can be overridden through the environment, and an
683+
// override cannot alter a consent record that already exists but it can
684+
// produce wrong new ones, so this log — not the config repository — is what
685+
// says what a deployment was serving.
686+
func logConsentDocuments(logger *slog.Logger, documents []consent.Document) {
687+
if len(documents) == 0 {
688+
logger.Info("consent disabled, no documents required at signup")
689+
return
690+
}
691+
logger.Info("consent enabled", "documents", len(documents))
692+
for _, document := range documents {
693+
logger.Info("consent document",
694+
"id", document.ID,
695+
"title", document.Title,
696+
"version", document.Version,
697+
"url", document.URL)
698+
}
699+
}
700+
671701
// StripeTransport wraps the default http.RoundTripper to add metrics.
672702
type StripeTransport struct {
673703
Base http.RoundTripper

config/sample.config.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,35 @@ app:
202202
# this is used to validate the webhook payloads
203203
encryption_key: "hash-secret-should-be-32-chars--"
204204

205+
# documents a user has to accept before an account is created.
206+
# frontier stores one consent record per signup, copying each document's
207+
# version and url into it, and never reads what is behind the url.
208+
consent:
209+
# false (the default) keeps the behaviour frontier had before consent
210+
# existed: ListConsentDocuments returns an empty list and no signup is
211+
# gated. true with no documents fails at boot rather than doing nothing.
212+
enabled: false
213+
# keyed by document id. the key is what the client sends back as
214+
# accepted_document_ids, and every document listed here is required at
215+
# signup — there is no per-document "required" flag.
216+
# version is opaque: it is compared for equality only, so dates, semver or
217+
# commit SHAs all work, and it is a version bump, not the url, that makes a
218+
# new document. config is read at boot, so changing any of this needs a
219+
# restart.
220+
documents:
221+
terms_of_service:
222+
title: "Terms & Conditions"
223+
version: "2026-04-01"
224+
url: "https://example.org/legal/terms/2026-04-01"
225+
privacy_policy:
226+
title: "Privacy Policy"
227+
version: "2026-04-01"
228+
url: "https://example.org/legal/privacy/2026-04-01"
229+
eula:
230+
title: "End User License Agreement"
231+
version: "2026-02-14"
232+
url: "https://example.org/legal/eula/2026-02-14"
233+
205234
# metaschema cache configuration
206235
metaschema:
207236
# how often each server reloads the metaschema cache from the database, so a

core/consent/config.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package consent
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"sort"
7+
)
8+
9+
// Config lists the documents a deployment asks people to accept before an
10+
// account is created. It sits at app.consent, beside app.authentication and
11+
// app.pat.
12+
//
13+
// Documents is keyed by document id rather than being a list: it matches
14+
// authenticate.Config keying oidc_config by strategy name, the key enforces
15+
// unique ids, and a single field stays env-overridable.
16+
//
17+
// Every document in the map is required at signup, so there is no per-document
18+
// required flag. An optional document would need withdrawal, which is out of
19+
// scope.
20+
type Config struct {
21+
// Enabled switches the whole feature. Absent or false, frontier behaves as
22+
// it did before consent existed.
23+
Enabled bool `yaml:"enabled" mapstructure:"enabled" default:"false"`
24+
25+
// Documents is keyed by document id.
26+
Documents map[string]DocumentConfig `yaml:"documents" mapstructure:"documents"`
27+
}
28+
29+
type DocumentConfig struct {
30+
Title string `yaml:"title" mapstructure:"title"`
31+
// Version is opaque to frontier. It is copied into the consent record and
32+
// compared for equality, never parsed.
33+
Version string `yaml:"version" mapstructure:"version"`
34+
// URL points at the document. Frontier never reads what is behind it.
35+
URL string `yaml:"url" mapstructure:"url"`
36+
}
37+
38+
// Validate reports whether the block can be served. It runs at boot, so bad
39+
// config stops the server rather than surfacing on a signup.
40+
//
41+
// A disabled block is not checked at all: nothing reads it, so a half-written
42+
// documents map on a deployment that has not turned consent on yet is not an
43+
// error. Turning it on is what makes it one.
44+
func (c Config) Validate() error {
45+
if !c.Enabled {
46+
return nil
47+
}
48+
49+
// enabled with no documents fails here rather than silently disabling
50+
// itself, which would look identical to a working deployment while
51+
// asking nobody to accept anything.
52+
if len(c.Documents) == 0 {
53+
return fmt.Errorf("app.consent is enabled but configures no documents")
54+
}
55+
56+
// map iteration order is random, so sort the ids to keep the error
57+
// deterministic across boots.
58+
for _, id := range sortedIDs(c.Documents) {
59+
doc := c.Documents[id]
60+
if id == "" {
61+
return fmt.Errorf("app.consent has a document with an empty id")
62+
}
63+
if doc.Version == "" {
64+
return fmt.Errorf("app.consent document %q has an empty version", id)
65+
}
66+
if doc.URL == "" {
67+
return fmt.Errorf("app.consent document %q has an empty url", id)
68+
}
69+
parsed, err := url.Parse(doc.URL)
70+
if err != nil {
71+
return fmt.Errorf("app.consent document %q has an unparseable url %q: %w", id, doc.URL, err)
72+
}
73+
// url.Parse accepts a bare path, and a document the client cannot link
74+
// to is as useless as one that does not parse at all.
75+
if !parsed.IsAbs() || parsed.Host == "" {
76+
return fmt.Errorf("app.consent document %q needs an absolute url with a host, got %q", id, doc.URL)
77+
}
78+
}
79+
return nil
80+
}
81+
82+
func sortedIDs(documents map[string]DocumentConfig) []string {
83+
ids := make([]string, 0, len(documents))
84+
for id := range documents {
85+
ids = append(ids, id)
86+
}
87+
sort.Strings(ids)
88+
return ids
89+
}

core/consent/config_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package consent_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/raystack/frontier/core/consent"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestConfig_Validate(t *testing.T) {
12+
t.Run("accepts a fully configured block", func(t *testing.T) {
13+
require.NoError(t, enabledConfig().Validate())
14+
})
15+
16+
t.Run("accepts a disabled block", func(t *testing.T) {
17+
require.NoError(t, consent.Config{}.Validate())
18+
})
19+
20+
t.Run("does not check a disabled block", func(t *testing.T) {
21+
// a half-written documents map on a deployment that has not turned
22+
// consent on yet is not an error; turning it on is what makes it one
23+
config := consent.Config{
24+
Documents: map[string]consent.DocumentConfig{
25+
"terms_of_service": {},
26+
},
27+
}
28+
29+
require.NoError(t, config.Validate())
30+
31+
config.Enabled = true
32+
require.Error(t, config.Validate())
33+
})
34+
35+
t.Run("rejects enabled with no documents", func(t *testing.T) {
36+
// silently disabling itself would look identical to a working
37+
// deployment while asking nobody to accept anything
38+
err := consent.Config{Enabled: true}.Validate()
39+
40+
require.Error(t, err)
41+
assert.Contains(t, err.Error(), "no documents")
42+
})
43+
44+
t.Run("rejects an empty id", func(t *testing.T) {
45+
config := consent.Config{
46+
Enabled: true,
47+
Documents: map[string]consent.DocumentConfig{
48+
"": {Title: "Terms", Version: "1", URL: "https://example.org/terms"},
49+
},
50+
}
51+
52+
err := config.Validate()
53+
54+
require.Error(t, err)
55+
assert.Contains(t, err.Error(), "empty id")
56+
})
57+
58+
t.Run("rejects an empty version", func(t *testing.T) {
59+
config := enabledConfig()
60+
config.Documents["terms_of_service"] = consent.DocumentConfig{
61+
Title: "Terms & Conditions",
62+
URL: "https://example.org/legal/terms",
63+
}
64+
65+
err := config.Validate()
66+
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), "terms_of_service")
69+
assert.Contains(t, err.Error(), "empty version")
70+
})
71+
72+
t.Run("rejects an empty url", func(t *testing.T) {
73+
config := enabledConfig()
74+
config.Documents["privacy_policy"] = consent.DocumentConfig{
75+
Title: "Privacy Policy",
76+
Version: "2026-04-01",
77+
}
78+
79+
err := config.Validate()
80+
81+
require.Error(t, err)
82+
assert.Contains(t, err.Error(), "privacy_policy")
83+
assert.Contains(t, err.Error(), "empty url")
84+
})
85+
86+
t.Run("rejects a url that does not parse", func(t *testing.T) {
87+
config := enabledConfig()
88+
config.Documents["eula"] = consent.DocumentConfig{
89+
Title: "End User License Agreement",
90+
Version: "2026-02-14",
91+
URL: "://example.org/legal/eula",
92+
}
93+
94+
err := config.Validate()
95+
96+
require.Error(t, err)
97+
assert.Contains(t, err.Error(), "eula")
98+
})
99+
100+
t.Run("rejects a url the client cannot link to", func(t *testing.T) {
101+
// url.Parse accepts a bare path, and a document nobody can open is as
102+
// useless as one that does not parse at all
103+
config := enabledConfig()
104+
config.Documents["eula"] = consent.DocumentConfig{
105+
Title: "End User License Agreement",
106+
Version: "2026-02-14",
107+
URL: "example.org/legal/eula",
108+
}
109+
110+
err := config.Validate()
111+
112+
require.Error(t, err)
113+
assert.Contains(t, err.Error(), "absolute url")
114+
})
115+
116+
t.Run("does not require a title", func(t *testing.T) {
117+
// the RFC requires ids, versions and URLs to be non-empty, and stops
118+
// there; an untitled document renders badly but serves correctly
119+
config := enabledConfig()
120+
config.Documents["eula"] = consent.DocumentConfig{
121+
Version: "2026-02-14",
122+
URL: "https://example.org/legal/eula",
123+
}
124+
125+
require.NoError(t, config.Validate())
126+
})
127+
128+
t.Run("names the same document on every run", func(t *testing.T) {
129+
// map iteration is randomised, so a validator that walks it unsorted
130+
// reports a different document each boot
131+
config := consent.Config{
132+
Enabled: true,
133+
Documents: map[string]consent.DocumentConfig{
134+
"aaa_broken": {Version: "1"},
135+
"zzz_broken": {Version: "1"},
136+
},
137+
}
138+
139+
for i := 0; i < 20; i++ {
140+
err := config.Validate()
141+
require.Error(t, err)
142+
assert.Contains(t, err.Error(), "aaa_broken")
143+
}
144+
})
145+
}

core/consent/consent.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package consent
2+
3+
// Document is one document a user has to accept before an account is created,
4+
// as config holds it. Frontier never reads what is behind URL, and Version is
5+
// opaque: it is compared for equality only, so dates, semver or commit SHAs
6+
// all work.
7+
type Document struct {
8+
ID string
9+
Title string
10+
Version string
11+
URL string
12+
}

core/consent/errors.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package consent
2+
3+
import "errors"
4+
5+
var (
6+
// ErrUnknownDocuments is returned when the caller accepts an id this
7+
// deployment does not configure. The ids the client rendered can differ
8+
// from config, and this is what exposes the mismatch.
9+
ErrUnknownDocuments = errors.New("unknown consent document ids")
10+
11+
// ErrMissingDocuments is returned when the accepted ids do not cover every
12+
// configured document. Every document in config is required at signup.
13+
ErrMissingDocuments = errors.New("missing consent document ids")
14+
)

0 commit comments

Comments
 (0)