Skip to content

Contract

Identity and access

Key every person by (iss, sub), never by email. A new account has zero permissions; your app grants access and can invite people by verified email before their first login.

The key of a person in your app is the pair (iss, sub): the issuer that signed the token and the stable identifier it gave the person. The same sub is valid in every app of the same issuer.

Your app must

Key by (iss, sub), never by email. Email changes, is reused and does not prove identity. Never link accounts because emails match.

Your app must

Same sub from another issuer: another person. Store both fields, even with one issuer today.

Your app must

Attributes change; the person does not. A new name or email at the IdP updates the attributes in your app at the next login; it never creates a new person.

The IdP guarantees

sub is stable. It survives attribute changes, engine upgrades and a rebuild from backup.

The IdP says who the person is. What they may do (roles, projects, data, bans) lives in your app, keyed by (iss, sub).

Your app must

A new account has zero permissions. Signing in creates or refreshes the person’s record and grants nothing. Each permission is an explicit act of your app, in your app’s audit trail.

Your app must

Deny access in your app, not in the IdP. Remove the person’s permissions or mark them banned in your records; they keep their Institute account for the other apps. Disabling an account at the IdP ends it for every app and is an IdP administration act.

There is no IdP API to look up or create users. To grant access before the first login, record an invitation addressed to the email and bind it to the person at their first login.

Your app must

An invitation binds once, to a verified email. At login, if the ID token has email_verified: true and an open invitation matches its email (case-insensitive), grant the invitation’s permissions to that (iss, sub) and close the invitation. If email_verified is not true, grant nothing. After binding, the email is only an attribute: a later change of email moves no access, and the old address grants nothing to whoever takes it over.

email_verified is true only after the person proved control of the address by link (self-service sign-up and accounts created by the IdP administration alike; never set by hand). An email belongs to at most one account. The invitation is the only place where an email decides anything, and only once.

people.sql (PostgreSQL)
create table person (
id bigint generated always as identity primary key,
issuer text not null,
subject text not null, -- opaque string, not a UUID type
email text, -- attribute: changes, identifies no one
name text,
unique (issuer, subject)
);
create table permission (
person_id bigint not null references person(id),
name text not null, -- your app's own vocabulary
primary key (person_id, name)
);
create table invitation (
email text primary key, -- lower-cased
permission text not null,
created_by bigint references person(id)
);
-- At every login: create the person or refresh their attributes. Grants nothing.
insert into person (issuer, subject, email, name)
values ($1, $2, $3, $4)
on conflict (issuer, subject)
do update set email = excluded.email, name = excluded.name
returning id;
-- Only when the ID token says email_verified = true: bind and close the invitation.
with claimed as (
delete from invitation where email = lower($3) returning permission
)
insert into permission (person_id, name)
select $5, permission from claimed
on conflict do nothing;

With this schema: a new email at the IdP updates the same row; the same sub from another issuer creates another row; two subs with the same email stay two people; an account that signs in without an invitation has no permission rows. The Node.js server does the same with in-memory maps.

CAFe and gov.br will come in as identity providers of the IdP, not of the apps. A person who signs in through CAFe is the same (iss, sub) of IdP Urupema; your app does not change. Linking an external identity to an Institute account takes an explicit, proven flow; a matching name or email is never enough. Not available yet.