Consent

privacy

Consent

Definition

Consent, under the General Data Protection Regulation (GDPR, EU 2016/679), is one of six lawful bases for processing personal data. Article 4(11) defines consent as "any freely given, specific, informed and unambiguous indication of the data subject's wishes by which he or she, by a statement or by a clear affirmative action, signifies agreement to the processing of personal data relating to him or her." This definition sets a high bar: pre-ticked boxes, silence, and inactivity do not constitute valid consent.

The four requirements for valid GDPR consent are: freely given (no coercion, power imbalance, or bundled consent), specific (tied to a particular processing purpose), informed (clear explanation of what data is collected and why), and unambiguous (requiring a clear affirmative act). Additionally, consent must be withdrawable at any time, with withdrawal as easy as granting consent. Controllers must maintain auditable records demonstrating that valid consent was obtained.

Overview

The Six Lawful Bases for Processing

Consent is one of six lawful bases under GDPR Article 6(1). Understanding where consent sits among alternatives is crucial for choosing the right legal basis:

Lawful BasisArticleWhen to UseConsent Needed?
Consent6(1)(a)Optional data collection (marketing, analytics)Yes, explicit
Contract6(1)(b)Necessary for service deliveryNo
Legal Obligation6(1)(c)Required by law (tax records, AML)No
Vital Interests6(1)(d)Life-threatening situationsNo
Public Interest6(1)(e)Government/public authority tasksNo
Legitimate Interest6(1)(f)Balanced business needs (fraud prevention)No, but requires balancing test

A common mistake is defaulting to consent when another basis is more appropriate. Using consent when contract performance suffices creates unnecessary complexity and gives users a withdrawal right that could disrupt service delivery.

flowchart LR
    A[Request Consent] --> B{User Decision}
    B -->|Grants| C[Record Consent]
    B -->|Refuses| D[No Processing]
    C --> E[Process Data]
    E --> F{Consent Still Valid?}
    F -->|Yes| E
    F -->|Withdrawn| G[Stop Processing]
    F -->|Expired| H[Re-request Consent]
    G --> I[Delete/Anonymize Data]
    H --> A

    C --> J[Audit Trail Entry]
    G --> K[Audit Trail Entry]
PatternScopeBindingRevocableGDPR Article
ConsentSpecific purposeLegally bindingYes, any timeArt. 6(1)(a)
Opt-inMarketing preferenceBusiness ruleYesArt. 7
Opt-outDefault processingBusiness ruleYesNot sufficient for GDPR
NoticeInforming about processingOne-wayN/AArt. 13/14
AgreementContract termsLegally bindingPer contract termsArt. 6(1)(b)

Technical Deep Dive

RequirementArticleDescriptionValidation Method
Freely GivenArt. 7(4)Not a condition for service, no power imbalanceUnbundled consent checks
SpecificArt. 6(1)(a)Per-purpose consent, not blanketPurpose limitation audit
InformedArt. 13/14Clear, plain language, identity of controllerReadability analysis (Flesch score)
UnambiguousArt. 4(11)Clear affirmative action, no pre-ticked boxesUI pattern analysis
WithdrawableArt. 7(3)Easy withdrawal mechanism, as easy as grantingWithdrawal flow test
DocumentedArt. 7(1)Demonstrable consent record with timestampAudit trail verification
Age-verifiedArt. 8Parental consent for children (<16 in most EU states)Age gate validation

Special Categories (Article 9)

Processing of sensitive data (health, biometrics, political opinions, ethnic origin, religious beliefs, sexual orientation) requires explicit consent -- a higher bar than standard consent. Explicit consent must be:

  • Expressly confirmed in words ("I consent to...")
  • Not inferred from conduct
  • Specific to each category of sensitive data
  • Documented with the exact wording presented

The consent management architecture follows an append-only, event-sourced pattern. Every consent action produces an immutable record. Current consent state is derived from the latest record per (subject, purpose) pair.

defmodule PrismaticPrivacy.ConsentRecord do
  @moduledoc """
  Immutable consent record for GDPR compliance.
  
  Every consent grant and withdrawal produces an auditable record.
  Records are append-only -- consent state is derived from the
  latest record per (subject, purpose) pair. This design satisfies
  GDPR Article 7(1): "the controller shall be able to demonstrate
  that the data subject has consented."
  """

  use Ecto.Schema
  import Ecto.Changeset

  @type action :: :granted | :withdrawn
  @type t :: %__MODULE__{
    id: Ecto.UUID.t(),
    subject_id: String.t(),
    purpose: String.t(),
    action: action(),
    legal_basis: String.t(),
    version: String.t(),
    consent_text: String.t(),
    ip_address: String.t() | nil,
    user_agent: String.t() | nil,
    metadata: map(),
    recorded_at: DateTime.t()
  }

  @primary_key {:id, :binary_id, autogenerate: true}
  schema "consent_records" do
    field :subject_id, :string
    field :purpose, :string
    field :action, Ecto.Enum, values: [:granted, :withdrawn]
    field :legal_basis, :string
    field :version, :string
    field :consent_text, :string
    field :ip_address, :string
    field :user_agent, :string
    field :metadata, :map, default: %{}
    field :recorded_at, :utc_datetime_usec
  end

  @doc """
  Creates a validated consent record changeset.
  All required fields are enforced -- incomplete consent records
  are rejected to maintain audit integrity.
  """
  @spec changeset(t(), map()) :: Ecto.Changeset.t()
  def changeset(record, attrs) do
    record
    |> cast(attrs, [:subject_id, :purpose, :action, :legal_basis,
                     :version, :consent_text, :ip_address, :user_agent,
                     :metadata, :recorded_at])
    |> validate_required([:subject_id, :purpose, :action, :legal_basis,
                          :version, :recorded_at])
    |> validate_inclusion(:action, [:granted, :withdrawn])
    |> validate_length(:purpose, min: 1, max: 255)
  end
end
defmodule PrismaticPrivacy.ConsentManager do
  @moduledoc """
  Manages consent lifecycle: grant, verify, withdraw, audit.
  
  All operations produce immutable audit records in an append-only table.
  Consent state is always derived from the latest record, never cached,
  ensuring real-time accuracy for compliance verification.
  """

  alias PrismaticPrivacy.ConsentRecord
  alias Prismatic.Repo

  import Ecto.Query

  require Logger

  @spec grant_consent(String.t(), String.t(), map()) :: {:ok, ConsentRecord.t()} | {:error, Ecto.Changeset.t()}
  def grant_consent(subject_id, purpose, context) do
    attrs = %{
      subject_id: subject_id,
      purpose: purpose,
      action: :granted,
      legal_basis: "GDPR Art. 6(1)(a)",
      version: consent_version(purpose),
      consent_text: Map.get(context, :consent_text, ""),
      ip_address: Map.get(context, :ip_address),
      user_agent: Map.get(context, :user_agent),
      metadata: Map.get(context, :metadata, %{}),
      recorded_at: DateTime.utc_now()
    }

    %ConsentRecord{}
    |> ConsentRecord.changeset(attrs)
    |> Repo.insert()
    |> tap(fn
      {:ok, record} ->
        :telemetry.execute(
          [:prismatic, :privacy, :consent, :granted],
          %{count: 1},
          %{purpose: purpose, subject_id: subject_id}
        )
        Logger.info("Consent granted: subject=#{subject_id} purpose=#{purpose}")

      {:error, _} -> :ok
    end)
  end

  @spec withdraw_consent(String.t(), String.t()) :: {:ok, ConsentRecord.t()} | {:error, Ecto.Changeset.t()}
  def withdraw_consent(subject_id, purpose) do
    attrs = %{
      subject_id: subject_id,
      purpose: purpose,
      action: :withdrawn,
      legal_basis: "GDPR Art. 7(3)",
      version: consent_version(purpose),
      recorded_at: DateTime.utc_now()
    }

    %ConsentRecord{}
    |> ConsentRecord.changeset(attrs)
    |> Repo.insert()
    |> tap(fn
      {:ok, _} ->
        :telemetry.execute(
          [:prismatic, :privacy, :consent, :withdrawn],
          %{count: 1},
          %{purpose: purpose, subject_id: subject_id}
        )
        Logger.info("Consent withdrawn: subject=#{subject_id} purpose=#{purpose}")

      {:error, _} -> :ok
    end)
  end

  @doc """
  Checks whether a subject has active consent for a given purpose.
  Always reads from database (no caching) for real-time accuracy.
  """
  @spec has_consent?(String.t(), String.t()) :: boolean()
  def has_consent?(subject_id, purpose) do
    query = from r in ConsentRecord,
      where: r.subject_id == ^subject_id and r.purpose == ^purpose,
      order_by: [desc: r.recorded_at],
      limit: 1,
      select: r.action

    case Repo.one(query) do
      :granted -> true
      _ -> false
    end
  end

  @doc """
  Returns the complete consent audit trail for a subject.
  Ordered chronologically for compliance review.
  """
  @spec get_consent_audit(String.t()) :: [ConsentRecord.t()]
  def get_consent_audit(subject_id) do
    from(r in ConsentRecord,
      where: r.subject_id == ^subject_id,
      order_by: [asc: r.recorded_at]
    )
    |> Repo.all()
  end

  @doc """
  Returns all active consents for a subject (latest grant without subsequent withdrawal).
  """
  @spec active_consents(String.t()) :: [%{purpose: String.t(), granted_at: DateTime.t()}]
  def active_consents(subject_id) do
    # Get latest record per purpose using a window function
    subquery = from r in ConsentRecord,
      where: r.subject_id == ^subject_id,
      distinct: [r.purpose],
      order_by: [r.purpose, desc: r.recorded_at]

    from(r in subquery, where: r.action == :granted)
    |> Repo.all()
    |> Enum.map(&%{purpose: &1.purpose, granted_at: &1.recorded_at})
  end

  @spec consent_version(String.t()) :: String.t()
  defp consent_version(purpose) do
    # Version tracks privacy policy changes per purpose
    Application.get_env(:prismatic_privacy, :consent_versions, %{})
    |> Map.get(purpose, "1.0.0")
  end
end
defmodule PrismaticPrivacy.ConsentGate do
  @moduledoc """
  Plug-style consent verification for data processing pipelines.
  Ensures processing only occurs when valid consent exists.
  """

  @spec verify_consent!(String.t(), String.t()) :: :ok | no_return()
  def verify_consent!(subject_id, purpose) do
    unless PrismaticPrivacy.ConsentManager.has_consent?(subject_id, purpose) do
      raise PrismaticPrivacy.ConsentRequiredError,
        message: "No active consent for purpose '#{purpose}' from subject '#{subject_id}'",
        subject_id: subject_id,
        purpose: purpose
    end

    :ok
  end

  @doc """
  Wraps a processing function with consent verification.
  Processing only executes if consent is active.
  """
  @spec with_consent(String.t(), String.t(), (-> term())) :: {:ok, term()} | {:error, :no_consent}
  def with_consent(subject_id, purpose, processing_fn) do
    if PrismaticPrivacy.ConsentManager.has_consent?(subject_id, purpose) do
      {:ok, processing_fn.()}
    else
      {:error, :no_consent}
    end
  end
end

Usage in Prismatic Platform

The Prismatic Platform manages consent for platform users in two contexts:

  1. Analytics consent: Optional usage analytics for platform improvement
  2. Communication consent: Email notifications and updates

Both follow the append-only pattern with versioned consent text, ensuring that if the privacy policy changes, previously granted consents are associated with the version the user actually saw.

External Compliance Assessment (Perimeter)

The Perimeter compliance engine evaluates external organizations' consent mechanisms as part of GDPR scoring:

flowchart TD
    A[Perimeter Scan Target] --> B[Cookie Consent Analysis]
    A --> C[Privacy Policy Analysis]
    A --> D[Data Subject Rights Check]

    B --> B1{Pre-ticked boxes?}
    B1 -->|Yes| B2[Penalty: -20 points]
    B1 -->|No| B3[No penalty]

    B --> B4{Granular choices?}
    B4 -->|Yes| B5[Bonus: +10 points]
    B4 -->|No| B6[Penalty: -10 points]

    C --> C1{Plain language?}
    C1 -->|Flesch > 60| C2[Good readability]
    C1 -->|Flesch < 40| C3[Penalty: -15 points]

    D --> D1{Easy withdrawal?}
    D1 -->|Yes| D2[Bonus: +10 points]
    D1 -->|No| D3[Penalty: -20 points]

    B2 & B3 & B5 & B6 & C2 & C3 & D2 & D3 --> E[GDPR Consent Score]

The OSINT toolbox's operations must respect consent boundaries when processing personal data. Tools that return personal information include consent-relevance metadata:

defmodule PrismaticOsintCore.ConsentClassifier do
  @moduledoc """
  Classifies OSINT tool results by consent relevance.
  Flags results containing personal data that may require
  consent verification before further processing.
  """

  @personal_data_indicators ["email", "phone", "address", "name", "dob", "ssn"]

  @spec classify_result(map()) :: :public_data | :potentially_personal | :personal_data
  def classify_result(result) when is_map(result) do
    keys = result |> Map.keys() |> Enum.map(&to_string/1) |> Enum.map(&String.downcase/1)

    personal_matches = Enum.count(keys, fn key ->
      Enum.any?(@personal_data_indicators, &String.contains?(key, &1))
    end)

    cond do
      personal_matches >= 3 -> :personal_data
      personal_matches >= 1 -> :potentially_personal
      true -> :public_data
    end
  end
end

The Czech NIS2 implementation adds additional consent requirements for critical infrastructure entities. The CER compliance module tracks these alongside GDPR requirements.

Compliant Pattern (Flowbite-based)

<!-- FLLM-compliant cookie consent banner using Tailwind/Flowbite -->
<div id="cookie-consent" class="fixed bottom-0 inset-x-0 z-50 p-4 bg-gray-900 border-t border-gray-700">
  <div class="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
    <p class="text-sm text-gray-300">
      We use cookies to improve your experience. You can customize your preferences below.
    </p>
    <div class="flex gap-3">
      <button phx-click="consent-customize" class="text-sm text-blue-400 hover:text-blue-300">
        Customize
      </button>
      <button phx-click="consent-reject-all" class="px-4 py-2 text-sm bg-gray-700 text-white rounded-lg">
        Reject All
      </button>
      <button phx-click="consent-accept-all" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg">
        Accept All
      </button>
    </div>
  </div>
</div>

Non-Compliant Anti-Patterns

Anti-PatternGDPR ViolationWhy It Fails
Pre-ticked checkboxesArt. 4(11)Not an affirmative action
"By continuing to use..."Art. 4(11)Inactivity is not consent
No reject optionArt. 7(4)Consent not freely given
Bundled consent (all-or-nothing)Art. 6(1)(a)Not specific per purpose
Tiny "manage preferences" linkArt. 7(3)Withdrawal not as easy as granting
Cookie wall (block access without consent)Art. 7(4)Consent not freely given
No consent version trackingArt. 7(1)Cannot demonstrate what was consented to
Consent before informationArt. 13/14Not informed consent

Best Practices

  1. Choose the right legal basis -- don't default to consent if contract performance or legitimate interest applies. Consent creates withdrawal obligations that may disrupt service.
  2. Make consent granular -- separate consent per purpose (analytics, marketing, third-party sharing). Bundled consent is invalid under GDPR.
  3. Use append-only records -- never update or delete consent records. State is derived from the latest record.
  4. Version consent text -- when privacy policies change, old consents reference the version the user actually saw.
  5. Make withdrawal as easy as granting -- one-click withdrawal, prominently placed. Dark patterns around withdrawal violate Art. 7(3).
  6. Verify consent freshness -- consent doesn't last forever. Re-request after significant policy changes or after a reasonable time period.
  7. Log context -- record IP, user agent, timestamp, and exact consent text shown. This demonstrates compliance under audit.
  8. Never cache consent status -- always read from the authoritative source. Stale cache could process data after withdrawal.

Common Mistakes

MistakeGDPR RiskCorrect Approach
Using consent for everythingUnnecessary complexity, easy to lose basisUse contract/legitimate interest where applicable
Pre-ticked consent boxesArt. 4(11) violation, invalid consentRequire explicit affirmative action
No withdrawal mechanismArt. 7(3) violation, regulatory fineImplement easy one-click withdrawal
Caching consent statusProcessing after withdrawalAlways query latest record
Bundled all-or-nothing consentArt. 7(4) violationGranular per-purpose consent
No version trackingCannot prove what was consented toVersion every consent text change
Consent as condition for serviceArt. 7(4) violationSeparate optional from essential processing
Missing audit trailArt. 7(1) violationAppend-only immutable records
Not re-requesting after policy changeStale consentTrack consent-to-version mapping
Ignoring children's consentArt. 8 violationAge verification + parental consent
  • Compliance -- regulatory framework including GDPR consent requirements
  • GDPR -- the regulation defining consent requirements
  • NIS2 -- EU directive with additional data protection requirements
  • Audit Trail -- immutable record of consent actions
  • Authentication -- identity verification prerequisite to consent
  • Authorization -- access control informed by consent status
  • Data Breach -- security incident requiring consent-aware notification
  • Encryption -- technical measure protecting consented data
  • Credential -- authentication tokens subject to consent for analytics
  • Perimeter -- EASM module assessing external consent mechanisms
  • OSINT -- intelligence gathering with consent boundary awareness
  • Changeset -- Ecto validation enforcing consent record integrity
  • Event Sourcing -- architectural pattern underlying consent management
  • Data Quality -- quality of consent records and audit completeness

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL