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,
});Route: POST /api/iam/privacy/createPurpose
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',
});Routes: POST /api/iam/privacy/mine, POST /api/iam/privacy/decide
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 })),
});Routes: POST /api/iam/privacy/record, POST /api/iam/privacy/filterSubjects
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.
Options
Privacy has no deployment option: the privacy group is always available and its collections are part of the
ordinary migration (npx better-iam migrate or iam.initialize()). Request emails need authentication.sendEmail
and the outbox job, and deadline reminders need iam.privacy.sendDeadlineReminders() on a schedule. Each tenant
sets these with privacy.updateSettings (privacy.getSettings reads them):
| Setting | Type | Default | Description |
|---|---|---|---|
contactEmail | string | none | The privacy contact that receives request emails when nobody is assigned. |
contactName | string | none | The contact's name, shown to requesters. |
defaultRegulation | Regulation | 'gdpr' | The regulation a request is answered under when it names none. |
responseDays | Partial<Record<Regulation, number>> | none | Shorter internal response windows per regulation, never longer than the statutory ones. |
publicIntake | boolean | false | Accept requests from people without an account (submitPublic, confirmPublic). |
exportLifetimeDays | number | 14 | How long an access or portability export can be downloaded. |
Regulation is one of these, with its statutory windows:
| Regulation | Response days | Extension days |
|---|---|---|
gdpr | 30 | 60 |
uk-gdpr | 30 | 60 |
ccpa | 45 | 45 |
lgpd | 15 | 0 |
pipeda | 30 | 30 |
other | 30 | 30 |
A purpose's legalBasis is one of consent, contract, legal-obligation, vital-interests, public-task and
legitimate-interests, and its mode is opt-in or opt-out. A request's type is one of access,
portability, erasure, rectification, restriction, objection and opt-out. A tenant holds at most 200
purposes.
Schema
| Collection | One record per | Key fields |
|---|---|---|
privacyPurposes | Purpose (unique key per tenant) | key, name, description, legalBasis, mode, version, dataCategories, retentionDays, consentLifetimeDays, reconsentOnVersion, archived |
privacyConsents | Current decision of one subject for one purpose | purposeId, purposeKey, subject, identityId, externalId, granted, purposeVersion, source, method, recordedAt, recordedBy, expiresAt, receiptId |
privacyConsentEvents | Recorded decision (the signed receipt's history) | receiptId, purposeKey, purposeName, legalBasis, purposeVersion, subject, granted, source, evidence, ip, userAgent, recordedAt, redacted |
privacyRequests | Data-subject request | number, type, regulation, status (pending-verification, open, completed, rejected, cancelled), subject, channel (self-service, staff, public), verification, submittedAt, dueAt, extendedAt, assigneeId, rejectionReason, exportId, events |
privacyExports | Export of an access or portability request | requestId, subject, identityId, scope (full, provided), data, downloads, expiresAt |
privacyHolds | Legal hold | subject, identityId, externalId, reason, placedBy, placedAt, expiresAt |
privacyRestrictions | Restriction of processing | subject, identityId, externalId, requestId, erased, restrictedAt, restrictedBy |
privacySettings | Tenant (ID privacy:{tenantId}) | The settings in Options |
A subject is a member (identityId) or one of your own customers by your ID (externalId).
Routes
Every method is a POST under the handler's base path (default /api/iam). submitPublic and confirmPublic are
public; every other method needs a credential. The browser client calls the same routes as
client.privacy.<method>.
| Method | Route | Method | Route |
|---|---|---|---|
privacy.createPurpose | POST /api/iam/privacy/createPurpose | privacy.updatePurpose | POST /api/iam/privacy/updatePurpose |
privacy.deletePurpose | POST /api/iam/privacy/deletePurpose | privacy.listPurposes | POST /api/iam/privacy/listPurposes |
privacy.mine | POST /api/iam/privacy/mine | privacy.decide | POST /api/iam/privacy/decide |
privacy.myHistory | POST /api/iam/privacy/myHistory | privacy.myReceipt | POST /api/iam/privacy/myReceipt |
privacy.record | POST /api/iam/privacy/record | privacy.importDecisions | POST /api/iam/privacy/importDecisions |
privacy.check | POST /api/iam/privacy/check | privacy.filterSubjects | POST /api/iam/privacy/filterSubjects |
privacy.audience | POST /api/iam/privacy/audience | privacy.listConsents | POST /api/iam/privacy/listConsents |
privacy.history | POST /api/iam/privacy/history | privacy.verifyReceipt | POST /api/iam/privacy/verifyReceipt |
privacy.submitRequest | POST /api/iam/privacy/submitRequest | privacy.cancelMyRequest | POST /api/iam/privacy/cancelMyRequest |
privacy.submitPublic | POST /api/iam/privacy/submitPublic (public) | privacy.confirmPublic | POST /api/iam/privacy/confirmPublic (public) |
privacy.createRequest | POST /api/iam/privacy/createRequest | privacy.getRequest | POST /api/iam/privacy/getRequest |
privacy.listRequests | POST /api/iam/privacy/listRequests | privacy.verifyRequest | POST /api/iam/privacy/verifyRequest |
privacy.linkRequest | POST /api/iam/privacy/linkRequest | privacy.assignRequest | POST /api/iam/privacy/assignRequest |
privacy.addNote | POST /api/iam/privacy/addNote | privacy.extendRequest | POST /api/iam/privacy/extendRequest |
privacy.fulfilRequest | POST /api/iam/privacy/fulfilRequest | privacy.rejectRequest | POST /api/iam/privacy/rejectRequest |
privacy.downloadExport | POST /api/iam/privacy/downloadExport | privacy.summary | POST /api/iam/privacy/summary |
privacy.placeHold | POST /api/iam/privacy/placeHold | privacy.releaseHold | POST /api/iam/privacy/releaseHold |
privacy.listHolds | POST /api/iam/privacy/listHolds | privacy.listRestrictions | POST /api/iam/privacy/listRestrictions |
privacy.liftRestriction | POST /api/iam/privacy/liftRestriction | privacy.getSettings | POST /api/iam/privacy/getSettings |
privacy.updateSettings | POST /api/iam/privacy/updateSettings |
iam.privacy.sendDeadlineReminders() is the scheduled job, not a route.
Errors
| Code | Status | When |
|---|---|---|
LEGAL_HOLD | 409 | An erasure, or deleting the person or their organization, while a legal hold is live |
VERSION_CONFLICT | 409 | decide named a purpose version that is no longer current |
RESOURCE_IN_USE | 409 | Deleting a purpose that has recorded decisions; archive it instead |
CONFLICT | 409 | A purpose with the key exists, a decision for an archived purpose, an open request of the same type already exists, or a request was already extended (or its regulation allows no extension) or already linked |
LIMIT_EXCEEDED | 409 | The tenant would hold more than 200 purposes |
INVALID_TRANSITION | 409 | Acting on a closed request, or verifying one that is already verified |
CONFIRMATION_INVALID | 400 | confirmPublic with an invalid or expired confirmation link |
DELIVERY_REQUIRED | 400 | Turning on publicIntake, or submitting a public request, without an email delivery callback |
ACCESS_DENIED | 403 | A person's own choices made from anything but their signed-in session of the organization, or a missing iam:privacy:* permission |
IMPERSONATION_RESTRICTED | 403 | Making privacy choices from a "view as" session |
NOT_FOUND | 404 | An unknown purpose, request, export or hold |
INVALID_INPUT | 400 | A purpose, request, decision or setting that does not validate |
Better IAM is created by Sean Filimon
Last updated