Privacy and consent
Processing purposes with legal bases, signed consent receipts, data-subject requests with statutory deadlines, legal holds, and consent in policies.
Privacy laws ask the same things of every organization that handles personal data: know why you process it, record who agreed to what and prove it later, answer people who ask for their data (or ask you to delete it) within a legal deadline, and never erase what a court needs kept. Those records usually live in a separate consent tool and a ticket queue, far from the accounts they are about.
Better IAM keeps them next to the people. A tenant describes its purposes, each person's consent to them is
recorded with a signed receipt, data-subject requests run on the deadlines of GDPR, UK GDPR, CCPA/CPRA, LGPD and
PIPEDA, and legal holds stop erasure. Applications ask "may I process this purpose for this person?", and
see the answer as principal.consents.
Purposes and legal bases
privacy.createPurpose (iam:privacy:manage) adds a purpose at version 1:
await iam.api.privacy.createPurpose(admin, {
tenantId,
key: 'marketing-email',
name: 'Marketing email',
description: 'Product news and offers by email.',
legalBasis: 'consent',
dataCategories: ['contact'],
retentionDays: 730,
});Prop
Type
The legal basis decides who has a say, and how a missing decision is read:
| Basis | Who decides | Processing is allowed |
|---|---|---|
consent, opt-in | The person | Only with a recorded grant for the current version that has not lapsed. |
consent, opt-out | The person | Until the person opts out. |
legitimate-interests | The person may object | Until the person objects. |
contract | Nobody | Always, unless processing is restricted. |
legal-obligation | Nobody | Always, even while processing is restricted. |
vital-interests | Nobody | Always, even while processing is restricted. |
public-task | Nobody | Always, unless processing is restricted. |
A tenant holds at most 200 purposes. privacy.updatePurpose edits one: newVersion: true publishes the change as the
next version, so opt-in grants for older versions become CONSENT_OUTDATED and people are asked again. Changing the
legal basis or the consent mode is always material and needs newVersion: true. archived: true stops all
processing for a purpose and keeps its decisions; deletePurpose only removes purposes nobody has decided on.
People decide for themselves
A person manages their privacy from their own signed-in session, without any permission. Role sessions, API keys and agents acting for them are refused, and so is an administrator using .
const mine = await iam.api.privacy.mine(session, { tenantId });
// Every live purpose with `decidable`, `state` ({ allowed, reason }) and the person's decision,
// plus `restricted`, their requests, and the privacy contact.
const receipt = await iam.api.privacy.decide(session, {
tenantId,
purposeKey: 'marketing-email',
version: 1, // the version you showed: a changed purpose answers VERSION_CONFLICT
granted: true,
evidence: 'Signup form v3',
});granted: false withdraws consent, opts out, or objects to a legitimate-interest purpose. Every decision appends to
an append-only history (with the evidence, IP address and user agent) and returns a consent receipt: the decision
signed with an HMAC-SHA256 over its canonical JSON, keyed from the deployment secret. Receipts are rebuilt from the
history rather than stored. privacy.verifyReceipt (iam:privacy:read) checks one someone presents and says whether
it is still the current decision; receipts signed before a secret rotation keep verifying while the old secret is in
previousSecrets, and myReceipt hands the person a fresh copy.
The answer for each purpose is a reason: CONSENT_GIVEN, NOT_OPTED_OUT, LEGITIMATE_INTERESTS and LEGAL_BASIS
allow processing; NO_CONSENT, CONSENT_WITHDRAWN, CONSENT_EXPIRED, CONSENT_OUTDATED, OBJECTED, RESTRICTED,
ERASED and PURPOSE_ARCHIVED do not.
In React, usePrivacy from @better-iam/react loads the person's page and wraps the calls. pending lists the
opt-in purposes still waiting for an answer (never asked, or asked again for a new version), which is what a consent
banner shows, and decide records the choice for the version the person saw:
const { pending, decide, request } = usePrivacy({ tenantId });
// A banner: one button per unanswered purpose.
pending.map((purpose) => (
<button key={purpose.key} onClick={() => decide(purpose, true)}>
Allow {purpose.name}
</button>
));
// A privacy page: file an access request.
await request('access');Consent for your own customers
Applications often need consent from people without an account: shop customers, newsletter subscribers, website
visitors. Every method that takes a subject accepts { identityId } or { externalId }, an identifier your
application chooses (up to 256 characters).
// An API key with iam:privacy:record records a banner choice.
await iam.api.privacy.record(apiKey, {
tenantId,
subject: { externalId: 'cus_100' },
purposeKey: 'marketing-email',
granted: true,
method: 'banner',
ip: '203.0.113.9',
});
// A marketing send checks its recipients (iam:privacy:check).
const { allowed, refused } = await iam.api.privacy.filterSubjects(mailerKey, {
tenantId,
purposeKey: 'marketing-email',
subjects: recipients.map((recipient) => ({ externalId: recipient.id })),
});recordtakessource: 'import'with the originalrecordedAt, andimportDecisionsimports up to 500 decisions at once. Imports must state the purposeversionthe person decided on, and an older decision never replaces a newer one.checkanswers for one subject;filterSubjectsfor up to 1000;audiencelists everyone a purpose may be processed for now (names and emails only for callers who may read identities).- Server code checks and records without a credential through
iam.privacy.checkandiam.privacy.record.
Consent in policies
Decisions for a person in their own organization can read principal.consents: the keys of the consent and
legitimate-interest purposes that may be processed for them right now. A on it
grants something only to people who agreed:
{
"effect": "allow",
"actions": ["newsletters:read"],
"resources": ["*"],
"conditions": { "ArrayContains": { "principal.consents": ["marketing-email"] } }
}The server computes the key only when a document names it. Assumed roles and the keys of and agents see an empty list.
Data-subject requests
A request asks for access, portability, erasure, rectification, restriction, objection or opt-out. It is
answered under one regulation, whose deadline starts when the requester's identity is verified:
| Regulation | Answer within | Extension |
|---|---|---|
| GDPR, UK GDPR | 30 days | 60 days |
| CCPA | 45 days | 45 days |
| LGPD | 15 days | none |
PIPEDA, other | 30 days | 30 days |
An organization can set shorter internal windows (responseDays in privacy.updateSettings), never longer ones.
Requests arrive three ways:
- From the person, with
submitRequestfrom their own session. Signing in verified them, so the deadline starts at once and the privacy contact is emailed. - From staff, with
createRequest(iam:privacy:handle) for requests received by phone, mail or ticket. Withverified(how the handler confirmed who is asking) it opens at once; otherwise it waits forverifyRequest. - From a public form, when the organization turns on
publicIntake.submitPublicandconfirmPublicneed no credential: the requester confirms their address through an emailed link, and unconfirmed requests lapse after seven days. A confirmed address that belongs to a person with a verified email links the request to their account. A request that names anexternalIdstill waits for a handler'sverifyRequest, because owning an address does not prove owning the identifier.
A request known only by an email address cannot be fulfilled (apart from a rectification, done by hand) until a
handler links it to the account or application subject it is about (linkRequest), or declines it as no-data.
Fulfilling
fulfilRequest (iam:privacy:handle) does what the request asks and emails the subject:
- Access and portability build a JSON export (portability: only what the person provided), downloadable by the
person from their own session or by a handler for a limited time (14 days by default). They also need
iam:identities:readon the account and a recent sign-in. - Erasure deletes the account, erases its remaining details, deletes consent decisions and redacts their
history, and removes contact details from the person's requests. It needs
iam:identities:deleteon the account and a recent sign-in. An erased application subject stays suppressed (ERASED), so it cannot drift back to "allowed". - Restriction holds back every purpose except legal obligations and vital interests until a handler lifts it.
- Objection and opt-out record withdrawals for the named purposes, or all the ones the person has a say in.
- Rectification is done by hand; completing it needs a note of what was corrected.
extendRequest extends a deadline once where the regulation allows, and rejectRequest declines a request with a
reason the person is emailed.
Legal holds
A legal hold (placeHold, iam:privacy:manage) keeps a subject's data while litigation needs it. While it is live,
erasure is refused with LEGAL_HOLD, and so is every deletion of the person's account, because identity deletion
checks for holds itself. Release it with releaseHold, or let it lapse at its expiresAt; to refuse an erasure
request because the data must be kept, reject it as exempt.
Audit and emails
Consent decisions are audited as privacy:consent (imports as privacy:consent-import), requests as
privacy:request:* (submit, email-confirmed, verify, link, cancel, extend, complete, reject, due-soon, overdue),
erasures as privacy:erasure, export downloads as privacy:export:download, holds as privacy:hold:place and
privacy:hold:release, and lifted restrictions as privacy:restriction:lift. Subscribe a
to privacy:erasure to erase the person downstream too. The emails privacy-request-verify, privacy-request-received, privacy-request-update and
privacy-request-due link to your pages through the links.privacy template builder.
Better IAM is created by Sean Filimon
Last updated