Skip to content

Add the permission model with the Privacy Taxonomy vocabulary - #1045

Open
jwrosewell wants to merge 3 commits into
IABTechLab:mainfrom
jwrosewell:split/3-permissions
Open

Add the permission model with the Privacy Taxonomy vocabulary#1045
jwrosewell wants to merge 3 commits into
IABTechLab:mainfrom
jwrosewell:split/3-permissions

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Third of five stacked PRs decomposing #838 as requested in the #986 review. Stacks on #1044. Compare split/3-permissions to split/2-device-geo to see only this PR's change.

Spec: docs/superpowers/specs/2026-07-30-permission-model-design.md, the Tech Lab 2026-07-31 draft revised to match this implementation, with a revision-record table listing every divergence and why. Reader-facing documentation lands with it at docs/guide/permission-model.md.

What this PR does

Permissions become the primitive that gates identity features, and consent is one of several ways a permission is established (a country baseline, an opt-out signal, and configuration are others).

  • permissions.rs resolves a per-request permission state from the country and region baseline in permissions.yaml, augmented by the session's signals (TCF, GPP, GPC, US Privacy). Permission names follow the IAB Privacy Taxonomy Data Uses.
  • Signal precedence is fixed in code, most restrictive first. A US-style opt-out suppresses the Data Uses the policy revokes even when a TCF record consents, because an explicit opt-out is never overridden by another signal. A consent record that is present but cannot be decoded blocks baseline grants (fail-closed) instead of degrading to the no-signal baseline. Only then does a TCF record decide its mapped Data Uses. Pinning tests cover each opt-out source against a consenting TCF record.
  • Destructive withdrawal is narrow. Only a TCF record refusing storage in a jurisdiction whose baseline did not grant it expires the cookie and writes the identity-graph tombstone. Opt-outs suppress use (headers stripped, nothing egressed) but never destroy an issued identifier, so lifting the opt-out restores the identity.
  • Sharing beyond the edge (bidstream user.id, the identify response, partner pull-sync) requires storage plus personalised-ad selection, the same pair that gates bidstream EIDs, so a storage-only grant keeps first-party use while withholding partner sharing.
  • The Edge Cookie gate moves from raw consent to the permission model. A provider declares required_permissions() and core executes it only when every one is set.
  • [geo] default_country becomes required and is validated against permissions.yaml at startup, so there is always a defined permission baseline. A failed geo lookup is distinct from an unmatched country. It resolves at the requires-signal floor instead of the deployer default and is logged at error level. Geolocation is now off by default, and a deployment that runs an Edge Cookie provider with no geo provider must set [geo] assume_single_jurisdiction = true, acknowledging that every request is treated as the default jurisdiction.
  • permissions.yaml rules use an explicit per-permission acquisition map (granted, requires_signal, denied). Unknown keys in a rule and duplicate rule keys differing only by case are rejected at parse. An EU-27 plus EEA coverage test locks the gdpr-eu mapping.

How it was verified

Full local gate on this branch, all clean, including the reinstated opt-out precedence pinning tests and per-trigger withdrawal units. cargo test-fastly, cargo test-axum, cargo test-cloudflare, cargo test-spin, the integration parity suite, cargo fmt --check, and all six per-target clippy aliases.

Framing

Privacy is a spectrum and technology is neutral. This model encodes no jurisdiction's law. The deployer brings the policy in permissions.yaml and configuration, decides their own baselines, and the code makes those decisions inspectable and enforced. Trust comes from that flexibility being respected, not from constraint.

References #778. Decomposes #838. Spec baseline from #986.

Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.

…ider

First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.

- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
  The deprecated [ec] passphrase form still starts for one release
  cycle: it maps to provider = "hmac" with a deprecation warning, and a
  configuration carrying both forms is rejected. provider = "none"
  spells explicit statelessness. A configured block that is not the
  selected provider is rejected at startup, as is a block with no
  selector.
- Global identifier bounds enforced by core at mint, read-back, and
  cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
  cap. An identifier outside the bounds is rejected loudly, never
  rewritten, so the cookie value and the identity-graph key can never
  silently diverge.
- The identity graph is keyed by the provider's canonical form of the
  identifier (normalize_id_for_kv), so equivalent representations of
  one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
  access to the client IP, headers (including cookies), URL path, and
  query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
  provider, so a vendor provider lives in its own crate and core never
  names it. A selected provider the adapter does not inject fails the
  request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
  proceeding stateless.

Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.

Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.

The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.

Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
…e provider

Second slice of the PR 838 decomposition. Device classification and
geolocation become selectable providers, mirroring the Edge Cookie
provider seam:

- [device] provider selects the classifier. The built-in default reads
  the User-Agent alone and makes no host call; the opt-in fastly
  provider strengthens the browser/bot gate with the host's TLS JA4 and
  HTTP/2 signals (crates/device/fastly).
- [geo] provider selects geolocation. The host platform's lookup is the
  default, matching the behavior before the selector existed, and
  provider = "platform" spells the same choice explicitly
  (crates/geo/fastly wraps the Fastly host lookup behind the
  PlatformGeo trait). provider = "none" opts out entirely, so a client
  IP is never sent to any host geo service. The disabled-by-default
  flip ships with the permission model in the next slice, which adds
  the jurisdiction baseline that makes a no-geo deployment viable.
- Every adapter routes its host geo through the same build_geo_provider
  selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider
  identically, so the selector is not a Fastly-only behavior.
- The provider configuration sections ([device], [geo],
  [ec.providers.hmac], [ec.providers.host-signals]) reject unknown
  keys at startup, so a mistyped key fails loudly instead of silently
  selecting a default.
- The host-signal Edge Cookie provider arrives with the capability it
  needs: the Fastly adapter injects the TLS/HTTP-2 signals as a
  HostSignals service, and the provider mints from them plus the client
  IP. With no host signals at all it defers with a warning rather than
  degrading to an IP-only identifier.
- Device signals move to a field-based DeviceSignals derived in the
  adapter (derive_ua_only for hosts without host signals).
- The new crates join the fastly cargo aliases so they build, lint, and
  test in CI rather than compiling only transitively.
Third slice of the PR 838 decomposition. Permissions become the
primitive that gates identity features; consent is one of several ways
a permission is established:

- permissions.rs resolves a per-request PermissionState from the
  country/region baseline in permissions.yaml augmented by the session's
  signals (TCF, GPP, GPC, US Privacy). Permission names follow the
  Privacy Taxonomy Data Uses.
- Signal precedence is fixed in code, most restrictive first: a US-style
  opt-out (GPC, GPP sale opt-out, US Privacy) suppresses the Data Uses
  the policy revokes even when a TCF record consents, a present but
  undecodable record blocks baseline grants (fail-closed), and only then
  does a TCF record decide its mapped Data Uses. The yaml authoritative
  flag governs the TCF record's own grants and revokes, never whether an
  opt-out can be overridden. Pinning tests cover each opt-out source
  against a consenting TCF record.
- Destructive withdrawal is narrow: only a TCF record refusing storage
  in a jurisdiction whose baseline did not grant it expires the cookie
  and writes the identity-graph tombstone. Opt-outs suppress use
  (headers stripped, nothing egressed) but never destroy an issued
  identifier, so lifting an opt-out restores the identity.
- Sharing beyond the edge requires storage plus personalised-ad
  selection, the same pair that gates bidstream EIDs, at every egress:
  the auction endpoint's user.id, the publisher navigation and
  page-bids auction requests, the identify response, and partner
  pull-sync. A storage-only grant keeps first-party use while
  withholding partner sharing.
- The Edge Cookie gate moves from raw consent to the permission model:
  a provider declares required_permissions() and core executes it only
  when every one is set.
- [geo] default_country becomes required: it names the permissions.yaml
  rule that applies when the geo provider leaves a request unmatched. A
  failed geo lookup is distinct: it resolves at the requires-signal
  floor instead of the default, and is logged at error level. The
  lookup moves into EcContext::read_from_request_resolving_geo so every
  adapter reports the distinction identically.
- Geolocation is now off by default ([geo] provider unset resolves no
  location); the host lookup is opt-in via provider = "platform". A
  deployment that runs an Edge Cookie provider with no geo provider
  must set [geo] assume_single_jurisdiction = true, acknowledging that
  every request is treated as the default jurisdiction.
- permissions.yaml rules use an explicit per-permission acquisition map
  (granted / requires_signal / denied) instead of +/- sigils, unknown
  keys in a detailed rule are rejected, and two rule keys naming the
  same location in different case are rejected at parse.
- The consent module keeps building the ConsentContext; its EC-specific
  gating helpers move behind the permission model. An EU-27 plus EEA
  coverage test locks the gdpr-eu mapping.
- The design spec for this slice lives at
  docs/superpowers/specs/2026-07-30-permission-model-design.md, the
  2026-07-31 draft revised to match this implementation with a
  revision-record table of every divergence.
@jwrosewell
jwrosewell force-pushed the split/3-permissions branch from 5a707bf to 43e7dda Compare August 25, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant