Skip to content

Person Identifiers ​

Social Security numbers, passports, national IDs, driver's licences, student and employee numbers. They live in core.person_identifiers, are encrypted at rest, and every reveal is audited.

Encryption ​

Identifier values are stored AES-GCM encrypted in identifier_value_encrypted, alongside identifier_search_hash — an HMAC that makes exact-match lookup possible without decrypting anything.

Two server keys are required:

bash
IDENTIFIER_AES_KEY=...
IDENTIFIER_HMAC_KEY=...

On the server these take the SIS_-prefixed forms (SIS_IDENTIFIER_AES_KEY, SIS_IDENTIFIER_HMAC_KEY), and the demo tier has its own pair.

The plaintext column is empty. All 85,112 identifiers on file are encrypted; the legacy identifier_value column holds nothing, and migration 121 added a guard against plaintext being written back into it.

Revealing a value ​

Viewing an identifier is a distinct, separately granted action — three nodes, none of them menu items:

NodeGrants
PEOPLE.IDENTIFIER.REVEAL_LAST4See the last four digits
PEOPLE.IDENTIFIER.REVEAL_FULLSee the whole value
PEOPLE.IDENTIFIER.ACCESS_LOGRead the reveal audit log

Values are click-to-reveal in the interface, and each reveal writes to core.identifier_access_log (migration 120c). Migration 146a made that log survive deletion of the identifier it refers to — otherwise removing a record would erase the evidence that someone had looked at it.

Identifier types are configurable ​

core.lookup_identifier_type is an extended lookup: it carries behavior, not just a label, and has its own screen at Administration → Identifier Types (ADMIN.IDENTIFIER_TYPES, migration 193).

ColumnEffect
is_sensitiveWhether reveal gating and audit apply
uses_datesWhether issue and expiry dates are collected
allow_multipleWhether a person may hold more than one of this type
country_modeNONE, OPTIONAL, REQUIRED — is an issuing country collected?
state_modeLikewise for the state or province
input_maskEntry formatting
validation_regexAccepted format

The seeded types:

CodeLabelSensitiveDatesMultipleCountryState
SSNSocial Security NumberYesNoNoNONENONE
PASSPORTPassport NumberNoYesYesREQUIREDNONE
NATIONAL_IDNational IDNoNoYesREQUIREDOPTIONAL
DRIVER_LICENSEDriver's LicenseNoYesYesREQUIREDREQUIRED
STUDENT_IDStudent IDNoNoNoNONENONE
EMPLOYEE_IDEmployee IDNoNoNoNONENONE

The generic lookup editor refuses to write this type

POST, PATCH and DELETE on /core/lookup-values return 409 for IDENTIFIER_TYPE, naming /people/identifier-types. The generic INSERT would leave allow_multiple at its TRUE default — silently permitting duplicate SSNs — and the generic UPDATE rewrites code, which migrations and the importer key on. Create and delete exist only on the owning endpoint.

Where an identifier was issued ​

issuing_country_id (→ core.countries) and issuing_state_province_id (→ core.state_provinces, migration 194). A state is always chosen underneath a country, never instead of one.

state_mode = 'REQUIRED' is conditional in practice

Migration 171 seeded subdivisions for the United States and Canada only. So "required" means required where that country's subdivisions are on file — a driver's licence from a country with no subdivisions loaded does not demand one.

Two independent uniqueness rules ​

These are often confused, and they do different jobs.

1. allow_multiple = false — one of this type per person, wherever issued. Enforced in the service layer.

2. uq_person_identifiers_type_location (migration 195) — enforced by the database for every type:

sql
CREATE UNIQUE INDEX uq_person_identifiers_type_location
  ON core.person_identifiers
     (person_id, identifier_type_id,
      COALESCE(issuing_country_id, 0), COALESCE(issuing_state_province_id, 0));

So a dual national keeps two passports — different issuing countries — but not two passports from the same country.

Production is missing this index, deliberately

Migration 195 skips creating the index and warns when stored data would refuse it, rather than aborting a deploy. Production is in that state: one person has two SSN rows that must be adjudicated before the index can be created.

The index exists in this development database. Do not assume its presence when reasoning about production data.

What was dropped ​

Migration 176 dropped is_primary. There is no "primary identifier" concept — the type plus the issuing location identifies a row, and allow_multiple decides whether there can be more than one.

Student Information System Documentation