Admit by one rule on every sign-in and every use of a credential

A person is admitted when IAM says they administer the app's org, or when
an ADMIN vouches for them: through a membership that came from that ADMIN's
invitation, or through such an invitation pending for their verified
address. The rule (isAdmitted, with vouches: an ADMIN who is not disabled)
decides every IAM sign-in, linked or new. A linked account that fails it is
refused and its sessions and API tokens are deleted.

Sessions and API tokens work only while their holder is linked to IAM, not
disabled and admitted (credentialIsCurrent over readStanding); a team
token's holder is the organisation's owner. Accepting an invitation needs
its sender to vouch for it now, and the membership records that sender as
the member's inviter.

The cutoff is the migration that ships with this: 1_iam_only_sign_in
deletes every session and API token and adds OrganisationMember.invitedById
beside OrganisationMemberInvite.inviterId, NULL for every row already
standing, so nothing from before it vouches for anyone. IAM_ONLY_SINCE goes.
IAM_PROVIDER names the one provider in lib.

The e2e fixture admits its users through one ADMIN voucher. Tests cover the
decision for returning and first sign-ins, vouching, credentials, and the
migration against real SQLite.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-10-16 06:51:56 -07:00
co-authored by Hanzo Dev
parent 50eb056768
commit 472b2a4643
19 changed files with 514 additions and 228 deletions
@@ -6,6 +6,7 @@ import { useQuery } from '@tanstack/react-query';
import { DateTime } from 'luxon';
import { authClient } from '@hanzo/esign-auth/client';
import { IAM_PROVIDER } from '@hanzo/esign-lib/constants/auth';
import { Button } from '@hanzo/esign-ui/primitives/button';
import type { DataTableColumnDef } from '@hanzo/esign-ui/primitives/data-table';
import { DataTable } from '@hanzo/esign-ui/primitives/data-table';
@@ -58,7 +59,7 @@ export default function SettingsSecurityLinkedAccounts() {
id: 'actions',
// The Hanzo link is how this account signs in, so it has no unlink.
cell: ({ row }) =>
row.original.provider === 'hanzo' ? null : (
row.original.provider === IAM_PROVIDER ? null : (
<AccountUnlinkDialog
accountId={row.original.id}
provider={row.original.provider}
@@ -5,6 +5,7 @@ import { Form, Link } from 'react-router';
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
import { acceptOrganisationInvitation } from '@hanzo/esign-lib/server-only/organisation/accept-organisation-invitation';
import { isInvitedPerson } from '@hanzo/esign-lib/server-only/organisation/is-invited-person';
import { vouches } from '@hanzo/esign-lib/utils/iam-sign-in';
import { prisma } from '@hanzo/esign-prisma';
import { Button } from '@hanzo/esign-ui/primitives/button';
@@ -32,12 +33,20 @@ const readInvitation = async (request: Request, token: string | undefined) => {
name: true,
},
},
inviter: {
select: {
roles: true,
disabled: true,
},
},
},
});
// An invitation its sender no longer vouches for is as good as withdrawn.
if (
!organisationMemberInvite ||
organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED
organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED ||
!vouches(organisationMemberInvite.inviter)
) {
return { state: 'InvalidLink' } as const;
}
@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test';
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { NEXT_PUBLIC_WEBAPP_URL } from '@hanzo/esign-lib/constants/app';
import { formatSecureCookieName } from '@hanzo/esign-lib/constants/auth';
import { IAM_PROVIDER, formatSecureCookieName } from '@hanzo/esign-lib/constants/auth';
import { prisma } from '@hanzo/esign-prisma';
type LoginOptions = {
@@ -20,8 +20,9 @@ type LoginOptions = {
*
* The app signs people in through Hanzo IAM only, and a test cannot drive IAM.
* So the suite starts the session the way a completed IAM sign-in leaves it: the
* user's IAM link, a Session row, and the signed `sessionId` cookie that names
* it, signed with the app's own NEXT_PRIVATE_AUTH_SECRET exactly as hono signs it.
* user's IAM link and admission, a Session row, and the signed `sessionId`
* cookie that names it, signed with the app's own NEXT_PRIVATE_AUTH_SECRET
* exactly as hono signs it.
*/
export const apiSignin = async ({
page,
@@ -41,19 +42,48 @@ export const apiSignin = async ({
await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: 'hanzo',
provider: IAM_PROVIDER,
providerAccountId: `e2e-${user.id}`,
},
},
create: {
userId: user.id,
type: 'oauth',
provider: 'hanzo',
provider: IAM_PROVIDER,
providerAccountId: `e2e-${user.id}`,
},
update: {},
});
// And only for an admitted user: an ADMIN, or someone an ADMIN vouches for.
// A seeded user who is not ADMIN is vouched for through their memberships by
// one ADMIN that exists for the suite.
const voucher = await prisma.user.upsert({
where: {
email: '[email protected]',
},
create: {
email: '[email protected]',
name: 'E2E voucher',
emailVerified: new Date(),
roles: ['USER', 'ADMIN'],
},
update: {},
select: {
id: true,
},
});
await prisma.organisationMember.updateMany({
where: {
userId: user.id,
invitedById: null,
},
data: {
invitedById: voucher.id,
},
});
const token = randomBytes(20).toString('hex');
const id = createHash('sha256').update(token).digest('hex');
+92 -69
View File
@@ -1,12 +1,12 @@
/**
* What a Hanzo IAM sign-in leads to.
*
* A returning person is found by their linked IAM identity and let in unless
* disabled. A first sign-in makes an account only for someone who was asked in:
* an admin of the org the application serves, or a person an ADMIN invited
* under IAM-only sign-in. `isAdmin` is IAM's "admin of the account's own org",
* so it counts only when that org is the application's — an admin of a
* personal org of one is not an admin of Hanzo.
* Every sign-in, returning or first, passes one admission rule: IAM says the
* person administers the org the application serves, or an ADMIN vouches for
* them through a membership or a pending invitation to their verified address.
* `isAdmin` is IAM's "admin of the account's own org", so it counts only when
* that org is the application's — an admin of a personal org of one is not an
* admin of Hanzo.
*
* Run: npx tsx --test packages/auth/__tests__/iam-person.test.ts
*/
@@ -14,7 +14,6 @@ import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
import { IAM_ONLY_SINCE } from '@hanzo/esign-lib/utils/iam-sign-in';
import {
administersAppOrg,
@@ -125,8 +124,6 @@ describe('administersAppOrg', () => {
describe('decideSignIn', () => {
const sub = 'b474eaa5-e000-474d-b317-c37bbd9a6945';
const before = new Date(IAM_ONLY_SINCE.getTime() - 1);
const after = new Date(IAM_ONLY_SINCE.getTime() + 1);
const admin = () => readIamPerson(userinfo(), sub);
const member = (over: Record<string, unknown> = {}) =>
@@ -135,86 +132,112 @@ describe('decideSignIn', () => {
sub,
);
const first = { linked: null, addressHeldByUnlinkedAccount: false, invites: [] };
const nobody = {
linked: null,
addressHeldByUnlinkedAccount: false,
vouchedMembership: false,
vouchedInvite: false,
};
const returning = { ...nobody, linked: { id: 7, disabled: false } };
test('a returning linked person is let in without their address being asked about', () => {
const decision = decideSignIn({
...first,
person: member({ email_verified: false }),
linked: { id: 7, disabled: false },
describe('a returning, linked person', () => {
test('who is not an admin and whom no ADMIN vouches for is refused', () => {
// Every account the old sign-in linked, with nothing from after the cutoff.
assert.deepEqual(decideSignIn({ ...returning, person: member() }), {
kind: 'refuse',
reason: SignInRefusal.NotInvited,
});
});
assert.deepEqual(decision, { kind: 'sign-in', userId: 7 });
});
test('a returning linked person an admin disabled is refused', () => {
const decision = decideSignIn({
...first,
person: admin(),
linked: { id: 7, disabled: true },
test('who administers the app org is let in', () => {
assert.deepEqual(decideSignIn({ ...returning, person: admin() }), {
kind: 'sign-in',
userId: 7,
});
});
assert.deepEqual(decision, { kind: 'refuse', reason: SignInRefusal.Disabled });
});
test('who holds a membership an ADMIN invited them to is let in', () => {
assert.deepEqual(
decideSignIn({ ...returning, person: member(), vouchedMembership: true }),
{ kind: 'sign-in', userId: 7 },
);
});
test('a new admin of the app org gets an account', () => {
assert.deepEqual(decideSignIn({ ...first, person: admin() }), { kind: 'create' });
});
test('with a pending ADMIN invitation to their verified address is let in', () => {
assert.deepEqual(decideSignIn({ ...returning, person: member(), vouchedInvite: true }), {
kind: 'sign-in',
userId: 7,
});
});
test('a new non-admin with no invitation is refused', () => {
assert.deepEqual(decideSignIn({ ...first, person: member() }), {
kind: 'refuse',
reason: SignInRefusal.NotInvited,
test('with a pending invitation but an unverified address is refused', () => {
assert.deepEqual(
decideSignIn({
...returning,
person: member({ email_verified: false }),
vouchedInvite: true,
}),
{ kind: 'refuse', reason: SignInRefusal.NotInvited },
);
});
test('who is admitted is not asked about their address', () => {
assert.deepEqual(
decideSignIn({
...returning,
person: member({ email_verified: false }),
vouchedMembership: true,
}),
{ kind: 'sign-in', userId: 7 },
);
});
test('whom an admin disabled is refused, admitted or not', () => {
assert.deepEqual(
decideSignIn({ ...returning, person: admin(), linked: { id: 7, disabled: true } }),
{ kind: 'refuse', reason: SignInRefusal.Disabled },
);
});
});
test('an invitation from an ADMIN, sent under IAM-only sign-in, makes an account', () => {
const decision = decideSignIn({
...first,
person: member(),
invites: [{ createdAt: after, inviterIsAdmin: true }],
describe('a first sign-in', () => {
test('of an admin of the app org makes an account', () => {
assert.deepEqual(decideSignIn({ ...nobody, person: admin() }), { kind: 'create' });
});
assert.deepEqual(decision, { kind: 'create' });
});
test('an invitation from a sender who is not ADMIN makes no account', () => {
const decision = decideSignIn({
...first,
person: member(),
invites: [{ createdAt: after, inviterIsAdmin: false }],
test('of a non-admin with no invitation is refused', () => {
assert.deepEqual(decideSignIn({ ...nobody, person: member() }), {
kind: 'refuse',
reason: SignInRefusal.NotInvited,
});
});
assert.deepEqual(decision, { kind: 'refuse', reason: SignInRefusal.NotInvited });
});
test('an invitation from before IAM-only sign-in makes no account, whoever sent it', () => {
const decision = decideSignIn({
...first,
person: member(),
invites: [{ createdAt: before, inviterIsAdmin: true }],
test('with an invitation an ADMIN vouches for makes an account', () => {
assert.deepEqual(decideSignIn({ ...nobody, person: member(), vouchedInvite: true }), {
kind: 'create',
});
});
assert.deepEqual(decision, { kind: 'refuse', reason: SignInRefusal.NotInvited });
});
test('a first sign-in at an address IAM has not verified is refused, admin or not', () => {
const decision = decideSignIn({
...first,
person: readIamPerson(userinfo({ email_verified: false }), sub),
test('a membership cannot admit someone with no account', () => {
assert.deepEqual(decideSignIn({ ...nobody, person: member(), vouchedMembership: true }), {
kind: 'refuse',
reason: SignInRefusal.NotInvited,
});
});
assert.deepEqual(decision, { kind: 'refuse', reason: SignInRefusal.UnverifiedEmail });
});
test('an address held by an account never linked to this identity is refused, admin or not', () => {
const decision = decideSignIn({
...first,
person: admin(),
addressHeldByUnlinkedAccount: true,
test('at an address IAM has not verified is refused, admin or not', () => {
assert.deepEqual(
decideSignIn({ ...nobody, person: readIamPerson(userinfo({ email_verified: false }), sub) }),
{ kind: 'refuse', reason: SignInRefusal.UnverifiedEmail },
);
});
assert.deepEqual(decision, { kind: 'refuse', reason: SignInRefusal.UnlinkedAccount });
test('at an address held by an account never linked to this identity is refused', () => {
assert.deepEqual(
decideSignIn({ ...nobody, person: admin(), addressHeldByUnlinkedAccount: true }),
{ kind: 'refuse', reason: SignInRefusal.UnlinkedAccount },
);
});
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@hanzo/esign-lib/constants/app';
import { IAM_PROVIDER } from '@hanzo/esign-lib/constants/auth';
import { env } from '@hanzo/esign-lib/utils/env';
/**
@@ -22,7 +23,7 @@ const iamUrl = env('IAM_URL') ?? 'https://hanzo.id';
* account they arrive with is IAM's answer, read from its userinfo endpoint.
*/
export const HanzoAuthOptions: OAuthClientOptions = {
id: 'hanzo',
id: IAM_PROVIDER,
scope: ['openid', 'email', 'profile'],
clientId: env('IAM_CLIENT_ID') ?? '',
clientSecret: env('IAM_CLIENT_SECRET') ?? '',
+11 -22
View File
@@ -3,11 +3,12 @@ import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/enco
import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { readStanding } from '@hanzo/esign-lib/server-only/user/standing';
import type { RequestMetadata } from '@hanzo/esign-lib/universal/extract-request-metadata';
import { sessionIsCurrent } from '@hanzo/esign-lib/utils/iam-sign-in';
import { credentialIsCurrent } from '@hanzo/esign-lib/utils/iam-sign-in';
import { prisma } from '@hanzo/esign-prisma';
import { AUTH_SESSION_LIFETIME, HanzoAuthOptions } from '../../config';
import { AUTH_SESSION_LIFETIME } from '../../config';
/**
* The user object to pass around the app.
@@ -81,11 +82,10 @@ export const createSession = async (
/**
* The session a token names, or none.
*
* A session is honoured only while it is current (`sessionIsCurrent`): issued
* under IAM-only sign-in, to a user who is not disabled and whose account is
* linked to a Hanzo IAM identity. It lives for AUTH_SESSION_LIFETIME from the
* IAM sign-in that made it and is never extended, so a person IAM no longer
* admits is out within that lifetime.
* A session is honoured only while its holder's credentials are current
* (`credentialIsCurrent`): linked to a Hanzo IAM identity, not disabled, and
* admitted under the one admission rule. It lives for AUTH_SESSION_LIFETIME
* from the IAM sign-in that made it and is never extended.
*/
export const validateSessionToken = async (token: string): Promise<SessionValidationResult> => {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
@@ -108,16 +108,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
twoFactorEnabled: true,
roles: true,
signature: true,
disabled: true,
accounts: {
where: {
provider: HanzoAuthOptions.id,
},
select: {
id: true,
},
take: 1,
},
},
},
},
@@ -127,17 +117,16 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
return { session: null, user: null, isAuthenticated: false };
}
const { user: holder, ...session } = result;
const { disabled, accounts, ...user } = holder;
const { user, ...session } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await prisma.session.delete({ where: { id: sessionId } });
return { session: null, user: null, isAuthenticated: false };
}
if (
!sessionIsCurrent({ createdAt: session.createdAt, disabled, linkedToIam: accounts.length > 0 })
) {
const standing = await readStanding(user.id);
if (!standing || !credentialIsCurrent(standing)) {
// deleteMany: two requests on one refused session both reach this line.
await prisma.session.deleteMany({ where: { id: sessionId } });
return { session: null, user: null, isAuthenticated: false };
@@ -7,7 +7,7 @@ import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { onCreateUserHook } from '@hanzo/esign-lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/legacy-service-account';
import { isAdmin } from '@hanzo/esign-lib/utils/is-admin';
import { vouches } from '@hanzo/esign-lib/utils/iam-sign-in';
import { isValidReturnTo, normalizeReturnTo } from '@hanzo/esign-lib/utils/is-valid-return-to';
import { prisma } from '@hanzo/esign-prisma';
@@ -36,6 +36,8 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.text('FORBIDDEN', 403);
}
const voucher = { select: { roles: true, disabled: true } } as const;
const linked = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
@@ -47,46 +49,46 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
id: true,
disabled: true,
roles: true,
organisationMember: {
select: {
invitedBy: voucher,
},
},
},
},
},
});
// The store is asked only what a first sign-in needs to know.
const addressHeldByUnlinkedAccount =
!linked && (await prisma.user.count({ where: { email } })) > 0;
const invites = linked
? []
: await prisma.organisationMemberInvite.findMany({
where: {
email,
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
createdAt: true,
inviter: {
select: {
roles: true,
disabled: true,
},
},
},
});
const invites = await prisma.organisationMemberInvite.findMany({
where: {
email,
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
inviter: voucher,
},
});
const decision = decideSignIn({
person,
linked: linked ? { id: linked.user.id, disabled: linked.user.disabled } : null,
addressHeldByUnlinkedAccount,
invites: invites.map((invite) => ({
createdAt: invite.createdAt,
inviterIsAdmin: Boolean(
invite.inviter && !invite.inviter.disabled && isAdmin(invite.inviter),
),
})),
vouchedMembership: Boolean(
linked?.user.organisationMember.some((member) => vouches(member.invitedBy)),
),
vouchedInvite: invites.some((invite) => vouches(invite.inviter)),
});
if (decision.kind === 'refuse') {
// An account that fails admission keeps nothing that would let it back in.
if (linked) {
await prisma.session.deleteMany({ where: { userId: linked.user.id } });
await prisma.apiToken.deleteMany({ where: { userId: linked.user.id } });
}
return c.redirect(`/signin?error=${decision.reason}`, 302);
}
+25 -28
View File
@@ -2,7 +2,7 @@ import type { Role } from '@prisma/client';
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import { issuedUnderIamOnly } from '@hanzo/esign-lib/utils/iam-sign-in';
import { isAdmitted } from '@hanzo/esign-lib/utils/iam-sign-in';
import { AuthenticationErrorCode } from '../errors/error-codes';
@@ -93,13 +93,6 @@ export const rolesFor = (person: IamPerson, current: readonly Role[]): Role[] =>
return administersAppOrg(person) ? ['USER', 'ADMIN', ...rest] : ['USER', ...rest];
};
/** A pending invitation to the address IAM signed in, with its sender's standing. */
export type PendingInvite = {
createdAt: Date;
/** The sender holds ADMIN and is not disabled. */
inviterIsAdmin: boolean;
};
export type SignInDecision =
| { kind: 'sign-in'; userId: number }
| { kind: 'create' }
@@ -109,26 +102,38 @@ export type SignInDecision =
* What an IAM sign-in leads to, decided from what IAM said and what the store
* holds.
*
* A returning person is found by the IAM identity their account is linked to,
* and is let in unless an admin disabled them; IAM has already authenticated
* them, so their address is not asked about again. Everything else is a first
* sign-in, which makes an account only for someone who was asked in — an admin
* of the org the application serves, or a person an ADMIN invited under
* IAM-only sign-in — and only at an address IAM has verified that no unlinked
* account already holds.
* Every sign-in, returning or first, passes the one admission rule
* (`isAdmitted`): IAM says the person administers the app's org, or an ADMIN
* vouches for them through a membership or a pending invitation to their
* verified address. A returning person is found by the IAM identity their
* account is linked to; a disabled one is refused. A first sign-in also needs
* an address IAM has verified that no unlinked account already holds.
*/
export const decideSignIn = (facts: {
person: IamPerson;
linked: { id: number; disabled: boolean } | null;
addressHeldByUnlinkedAccount: boolean;
invites: PendingInvite[];
/** The linked account holds a membership whose inviter vouches for it. */
vouchedMembership: boolean;
/** A pending invitation to the address IAM signed in, whose sender vouches for it. */
vouchedInvite: boolean;
}): SignInDecision => {
const { person, linked } = facts;
const admitted = isAdmitted({
iamAdmin: administersAppOrg(person),
vouchedMembership: linked !== null && facts.vouchedMembership,
vouchedInvite: person.emailVerified && facts.vouchedInvite,
});
if (linked) {
return linked.disabled
? { kind: 'refuse', reason: SignInRefusal.Disabled }
: { kind: 'sign-in', userId: linked.id };
if (linked.disabled) {
return { kind: 'refuse', reason: SignInRefusal.Disabled };
}
return admitted
? { kind: 'sign-in', userId: linked.id }
: { kind: 'refuse', reason: SignInRefusal.NotInvited };
}
if (!person.emailVerified) {
@@ -139,13 +144,5 @@ export const decideSignIn = (facts: {
return { kind: 'refuse', reason: SignInRefusal.UnlinkedAccount };
}
const invited = facts.invites.some(
(invite) => invite.inviterIsAdmin && issuedUnderIamOnly(invite.createdAt),
);
if (administersAppOrg(person) || invited) {
return { kind: 'create' };
}
return { kind: 'refuse', reason: SignInRefusal.NotInvited };
return admitted ? { kind: 'create' } : { kind: 'refuse', reason: SignInRefusal.NotInvited };
};
+50 -40
View File
@@ -1,65 +1,75 @@
/**
* What survives the move to IAM-only sign-in.
* Who esign admits, and whose credentials keep working.
*
* A session or API token issued before IAM_ONLY_SINCE was issued under the old
* sign-in rules and is not honoured; neither is a session whose user is
* disabled or not linked to an IAM identity.
* One rule: an IAM admin of the app's org, or someone an ADMIN vouches for —
* through a membership or a pending invitation. An invitation or membership
* from before the cutoff carries no sender, and vouches for nobody. A session
* or API token works only while its holder (for a team token, the
* organisation's owner) is linked to IAM, not disabled, and admitted.
*
* Run: npx tsx --test packages/lib/__tests__/iam-sign-in.test.ts
*/
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
IAM_ONLY_SINCE,
apiTokenIsCurrent,
issuedUnderIamOnly,
sessionIsCurrent,
} from '../utils/iam-sign-in';
import type { Admission } from '../utils/iam-sign-in';
import { credentialIsCurrent, isAdmitted, vouches } from '../utils/iam-sign-in';
const before = new Date(IAM_ONLY_SINCE.getTime() - 1);
const after = new Date(IAM_ONLY_SINCE.getTime() + 1);
describe('issuedUnderIamOnly', () => {
test('the instant itself counts', () => {
assert.equal(issuedUnderIamOnly(IAM_ONLY_SINCE), true);
describe('vouches — whether an invitation may admit, or be accepted', () => {
test('an ADMIN who is not disabled vouches', () => {
assert.equal(vouches({ roles: ['USER', 'ADMIN'], disabled: false }), true);
});
test('a millisecond before does not', () => {
assert.equal(issuedUnderIamOnly(before), false);
test('no sender (an invitation or membership from before the cutoff) vouches for nobody', () => {
assert.equal(vouches(null), false);
});
test('a sender who is not ADMIN now does not vouch', () => {
assert.equal(vouches({ roles: ['USER'], disabled: false }), false);
});
test('a disabled ADMIN does not vouch', () => {
assert.equal(vouches({ roles: ['USER', 'ADMIN'], disabled: true }), false);
});
});
describe('sessionIsCurrent', () => {
const current = { createdAt: after, disabled: false, linkedToIam: true };
describe('isAdmitted', () => {
const none: Admission = { iamAdmin: false, vouchedMembership: false, vouchedInvite: false };
test('a session issued under IAM-only sign-in to a linked, enabled user', () => {
assert.equal(sessionIsCurrent(current), true);
test('nobody vouching admits nobody', () => {
assert.equal(isAdmitted(none), false);
});
test('a session from before IAM-only sign-in is refused', () => {
assert.equal(sessionIsCurrent({ ...current, createdAt: before }), false);
});
test('a disabled user is refused', () => {
assert.equal(sessionIsCurrent({ ...current, disabled: true }), false);
});
test('a user with no IAM link is refused', () => {
assert.equal(sessionIsCurrent({ ...current, linkedToIam: false }), false);
});
for (const key of ['iamAdmin', 'vouchedMembership', 'vouchedInvite'] as const) {
test(`${key} alone admits`, () => {
assert.equal(isAdmitted({ ...none, [key]: true }), true);
});
}
});
describe('apiTokenIsCurrent', () => {
test('a token minted under IAM-only sign-in to an enabled user', () => {
assert.equal(apiTokenIsCurrent({ createdAt: after, disabled: false }), true);
describe('credentialIsCurrent — sessions and API tokens', () => {
const admitted = { iamAdmin: false, vouchedMembership: true, vouchedInvite: false };
const current = { disabled: false, linkedToIam: true, admission: admitted };
test('a linked, enabled, admitted holder', () => {
assert.equal(credentialIsCurrent(current), true);
});
test('a token from before IAM-only sign-in is refused', () => {
assert.equal(apiTokenIsCurrent({ createdAt: before, disabled: false }), false);
test('a holder never linked to IAM is refused', () => {
assert.equal(credentialIsCurrent({ ...current, linkedToIam: false }), false);
});
test("a disabled holder's token is refused", () => {
assert.equal(apiTokenIsCurrent({ createdAt: after, disabled: true }), false);
test('a disabled holder is refused', () => {
assert.equal(credentialIsCurrent({ ...current, disabled: true }), false);
});
test('a holder no longer admitted is refused', () => {
assert.equal(
credentialIsCurrent({
...current,
admission: { iamAdmin: false, vouchedMembership: false, vouchedInvite: false },
}),
false,
);
});
});
+3
View File
@@ -12,6 +12,9 @@ export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
export const IS_IAM_SSO_ENABLED = Boolean(env('IAM_CLIENT_ID'));
/** The Account.provider of a Hanzo IAM link — the one way to sign in. */
export const IAM_PROVIDER = 'hanzo';
/**
* Why a Hanzo IAM sign-in was turned away. The callback redirects to
* `/signin?error=<reason>` and the sign-in page says it in a sentence, so the
@@ -4,7 +4,8 @@ import { decodeJwt, jwtVerify } from 'jose';
import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { apiTokenIsCurrent } from '../../utils/iam-sign-in';
import { credentialIsCurrent } from '../../utils/iam-sign-in';
import { readStanding } from '../user/standing';
export type VerifyEmbeddingPresignTokenOptions = {
token: string;
@@ -67,7 +68,6 @@ export const verifyEmbeddingPresignToken = async ({
id: true,
name: true,
email: true,
disabled: true,
},
},
},
@@ -79,9 +79,9 @@ export const verifyEmbeddingPresignToken = async ({
});
}
if (
!apiTokenIsCurrent({ createdAt: apiToken.createdAt, disabled: apiToken.user?.disabled ?? true })
) {
const standing = apiToken.userId ? await readStanding(apiToken.userId) : null;
if (!standing || !credentialIsCurrent(standing)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token not found',
});
@@ -6,6 +6,7 @@ import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { vouches } from '../../utils/iam-sign-in';
import { isInvitedPerson } from './is-invited-person';
export type AcceptOrganisationInvitationOptions = {
@@ -20,7 +21,10 @@ export type AcceptOrganisationInvitationOptions = {
* The token proves the invitation was received; it does not say who received
* it. So the invitation joins the person holding the session, and only when that
* person's verified address is the address it was sent to. Holding the link is
* never enough to put somebody else into the organisation.
* never enough to put somebody else into the organisation. And it is accepted
* only while its sender vouches for it (`vouches`: an ADMIN, not disabled) — the
* same rule that lets it admit its invitee — and the membership records that
* sender as the member's inviter.
*/
export const acceptOrganisationInvitation = async ({
token,
@@ -39,6 +43,12 @@ export const acceptOrganisationInvitation = async ({
groups: true,
},
},
inviter: {
select: {
roles: true,
disabled: true,
},
},
},
});
@@ -46,6 +56,13 @@ export const acceptOrganisationInvitation = async ({
throw new AppError(AppErrorCode.NOT_FOUND);
}
if (!vouches(organisationMemberInvite.inviter)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This invitation is no longer valid; ask an admin to invite you again',
statusCode: 403,
});
}
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
return;
}
@@ -91,6 +108,7 @@ export const acceptOrganisationInvitation = async ({
organisationId: organisation.id,
organisationGroups: organisation.groups,
organisationMemberRole: organisationMemberInvite.organisationRole,
invitedById: organisationMemberInvite.inviterId,
});
await prisma.organisationMemberInvite.update({
@@ -108,12 +126,15 @@ export const addUserToOrganisation = async ({
organisationId,
organisationGroups,
organisationMemberRole,
invitedById = null,
bypassEmail = false,
}: {
userId: number;
organisationId: string;
organisationGroups: OrganisationGroup[];
organisationMemberRole: OrganisationMemberRole;
/** The sender of the invitation this membership comes from. */
invitedById?: number | null;
bypassEmail?: boolean;
}) => {
const organisationGroupToUse = organisationGroups.find(
@@ -135,6 +156,7 @@ export const addUserToOrganisation = async ({
id: generateDatabaseId('member'),
userId,
organisationId,
invitedById,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
@@ -1,8 +1,9 @@
import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { apiTokenIsCurrent } from '../../utils/iam-sign-in';
import { credentialIsCurrent } from '../../utils/iam-sign-in';
import { hashString } from '../auth/hash';
import { readStanding } from '../user/standing';
export const getApiTokenByToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
@@ -68,9 +69,12 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
});
}
// A token minted under the old sign-in rules, or held by a disabled user, is
// not honoured.
if (!apiTokenIsCurrent({ createdAt: apiToken.createdAt, disabled: user.disabled })) {
// A token works only while its holder's credentials are current: linked to
// Hanzo IAM, not disabled, and admitted. A team token's holder is the
// organisation's owner.
const standing = await readStanding(user.id);
if (!standing || !credentialIsCurrent(standing)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
+68
View File
@@ -0,0 +1,68 @@
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@hanzo/esign-prisma';
import { IAM_PROVIDER } from '../../constants/auth';
import type { Standing } from '../../utils/iam-sign-in';
import { vouches } from '../../utils/iam-sign-in';
const voucher = { select: { roles: true, disabled: true } } as const;
/**
* A user's standing as the store holds it: disabled or not, linked to IAM or
* not, and the facts admission is decided from. ADMIN is IAM's answer recorded
* at the user's last sign-in. Null when the user does not exist.
*/
export const readStanding = async (userId: number): Promise<Standing | null> => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
select: {
email: true,
emailVerified: true,
roles: true,
disabled: true,
accounts: {
where: {
provider: IAM_PROVIDER,
},
select: {
id: true,
},
take: 1,
},
organisationMember: {
select: {
invitedBy: voucher,
},
},
},
});
if (!user) {
return null;
}
const invites = user.emailVerified
? await prisma.organisationMemberInvite.findMany({
where: {
email: user.email.toLowerCase(),
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
inviter: voucher,
},
})
: [];
return {
disabled: user.disabled,
linkedToIam: user.accounts.length > 0,
admission: {
iamAdmin: user.roles.includes('ADMIN'),
vouchedMembership: user.organisationMember.some((member) => vouches(member.invitedBy)),
vouchedInvite: invites.some((invite) => vouches(invite.inviter)),
},
};
};
+42 -22
View File
@@ -1,32 +1,52 @@
/**
* The instant esign began admitting people through Hanzo IAM alone — the time
* of the commit that made it so.
* Who esign admits, and what keeps working for them.
*
* Every session, API token and organisation invitation created before it was
* issued under the old sign-in rules (local passwords, other providers, and
* invitations any account could send). None of them is honoured: everyone
* signs in again through IAM, integrations mint new tokens, and an invitation
* from before it makes no account.
* ONE RULE, applied on every Hanzo IAM sign-in and on every use of a session or
* API token: a person is admitted when IAM says they administer the org the
* application serves (held as the ADMIN role, restated at each sign-in), or
* when an ADMIN vouches for them — through an organisation membership that came
* from that ADMIN's invitation, or through such an invitation still pending for
* their verified address.
*
* The cutoff is structural. Migration 1_iam_only_sign_in deletes every session
* and API token, and adds the columns that record who sent an invitation and
* who invited a member, NULL for every row from before it. A NULL sender vouches
* for nobody, so nothing issued under the old sign-in rules admits anyone.
*/
export const IAM_ONLY_SINCE = new Date('2026-10-16T09:51:53Z');
/** Whether something created at `createdAt` was issued under IAM-only sign-in. */
export const issuedUnderIamOnly = (createdAt: Date): boolean =>
createdAt.getTime() >= IAM_ONLY_SINCE.getTime();
/** The sender of an invitation, or the inviter of a member; null before the cutoff. */
export type Voucher = { roles: readonly string[]; disabled: boolean } | null;
/**
* Whether a session may still be used: issued under IAM-only sign-in, to a user
* who is not disabled and whose account is linked to a Hanzo IAM identity.
*/
export const sessionIsCurrent = (facts: {
createdAt: Date;
/** Whether an invitation's sender vouches for its invitee: an ADMIN who is not disabled. */
export const vouches = (voucher: Voucher): boolean =>
voucher !== null && !voucher.disabled && voucher.roles.includes('ADMIN');
/** What admission is decided from. */
export type Admission = {
/** IAM says the person administers the app's org (or ADMIN, recorded at sign-in). */
iamAdmin: boolean;
/** A membership whose inviter vouches for them. */
vouchedMembership: boolean;
/** A pending invitation to their verified address whose sender vouches for them. */
vouchedInvite: boolean;
};
/** The one admission rule. */
export const isAdmitted = (admission: Admission): boolean =>
admission.iamAdmin || admission.vouchedMembership || admission.vouchedInvite;
/** A user as the store holds them, for deciding whether their credentials still work. */
export type Standing = {
disabled: boolean;
/** The account is linked to a Hanzo IAM identity. */
linkedToIam: boolean;
}): boolean => issuedUnderIamOnly(facts.createdAt) && !facts.disabled && facts.linkedToIam;
admission: Admission;
};
/**
* Whether an API token may still be used: issued under IAM-only sign-in, to a
* user who is not disabled.
* Whether a session or API token held by this user may be used: they are linked
* to IAM, not disabled, and admitted. For a team token the holder is the
* organisation's owner.
*/
export const apiTokenIsCurrent = (facts: { createdAt: Date; disabled: boolean }): boolean =>
issuedUnderIamOnly(facts.createdAt) && !facts.disabled;
export const credentialIsCurrent = (standing: Standing): boolean =>
standing.linkedToIam && !standing.disabled && isAdmitted(standing.admission);
@@ -0,0 +1,91 @@
/**
* Migration 1_iam_only_sign_in is the cutoff for IAM-only sign-in.
*
* It deletes every session and API token — all issued under the old sign-in
* rules — and adds the columns that record who sent an invitation and who
* invited a member, NULL for every row already standing, so nothing from before
* it vouches for anyone. These tests run the REAL migrations (0_init, then this
* one) against a real SQLite database that holds rows from before it.
*
* Run: npx tsx --test packages/prisma/__tests__/iam-only-migration.test.ts
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { before, describe, test } from 'node:test';
const migration = (name: string) =>
readFileSync(path.join(__dirname, '..', 'migrations', name, 'migration.sql'), 'utf8');
const count = (db: DatabaseSync, table: string) =>
Number((db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as { n: number }).n);
let db: DatabaseSync;
before(() => {
db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys=OFF'); // seed the rows below without their whole graph
db.exec(migration('0_init'));
db.prepare('INSERT INTO "User" (id, email, roles) VALUES (?, ?, ?)').run(1, '[email protected]', '["USER"]');
db.prepare('INSERT INTO "User" (id, email, roles) VALUES (?, ?, ?)').run(2, '[email protected]', '["USER"]');
db.prepare(
'INSERT INTO "Session" (id, sessionToken, userId, expiresAt, updatedAt) VALUES (?, ?, ?, ?, ?)',
).run('s1', 's1', 1, Date.now() + 86_400_000, Date.now());
db.prepare('INSERT INTO "ApiToken" (name, token, userId, teamId) VALUES (?, ?, ?, ?)').run(
'ci',
'hash',
1,
1,
);
db.prepare(
'INSERT INTO "OrganisationMemberInvite" (id, email, token, organisationId, organisationRole) VALUES (?, ?, ?, ?, ?)',
).run('inv1', '[email protected]', 'tok', 'org1', 'MEMBER');
db.prepare(
'INSERT INTO "OrganisationMember" (id, userId, organisationId, updatedAt) VALUES (?, ?, ?, ?)',
).run('mem1', 2, 'org1', Date.now());
db.exec(migration('1_iam_only_sign_in'));
db.exec('PRAGMA foreign_keys=ON');
});
describe('1_iam_only_sign_in', () => {
test('deletes every session', () => {
assert.equal(count(db, 'Session'), 0);
});
test('deletes every API token', () => {
assert.equal(count(db, 'ApiToken'), 0);
});
test('keeps the people, invitations and memberships themselves', () => {
assert.equal(count(db, 'User'), 2);
assert.equal(count(db, 'OrganisationMemberInvite'), 1);
assert.equal(count(db, 'OrganisationMember'), 1);
});
test('an invitation from before it has no sender', () => {
const row = db.prepare('SELECT inviterId FROM "OrganisationMemberInvite" WHERE id = ?').get('inv1');
assert.deepEqual({ ...(row as object) }, { inviterId: null });
});
test('a membership from before it has no inviter', () => {
const row = db.prepare('SELECT invitedById FROM "OrganisationMember" WHERE id = ?').get('mem1');
assert.deepEqual({ ...(row as object) }, { invitedById: null });
});
test('deleting a sender clears what they vouched for rather than the rows', () => {
db.prepare('UPDATE "OrganisationMemberInvite" SET inviterId = 1 WHERE id = ?').run('inv1');
db.prepare('UPDATE "OrganisationMember" SET invitedById = 1 WHERE id = ?').run('mem1');
db.prepare('DELETE FROM "User" WHERE id = ?').run(1);
const invite = db.prepare('SELECT inviterId FROM "OrganisationMemberInvite" WHERE id = ?').get('inv1');
const member = db.prepare('SELECT invitedById FROM "OrganisationMember" WHERE id = ?').get('mem1');
assert.deepEqual({ ...(invite as object) }, { inviterId: null });
assert.deepEqual({ ...(member as object) }, { invitedById: null });
});
});
@@ -0,0 +1,12 @@
-- Who sent each invitation. NULL for every invitation already standing: those
-- were sent under the old sign-in rules and admit nobody.
ALTER TABLE "OrganisationMemberInvite" ADD COLUMN "inviterId" INTEGER CONSTRAINT "OrganisationMemberInvite_inviterId_fkey" REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Who invited each member. NULL for every membership already standing, for the
-- same reason.
ALTER TABLE "OrganisationMember" ADD COLUMN "invitedById" INTEGER CONSTRAINT "OrganisationMember_invitedById_fkey" REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Every session and API token was issued under the old sign-in rules. People
-- sign in again through Hanzo IAM; integrations mint new tokens.
DELETE FROM "Session";
DELETE FROM "ApiToken";
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "OrganisationMemberInvite" ADD COLUMN "inviterId" INTEGER CONSTRAINT "OrganisationMemberInvite_inviterId_fkey" REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
+8 -2
View File
@@ -68,6 +68,7 @@ model User {
ownedOrganisations Organisation[]
organisationMember OrganisationMember[]
invitationsSent OrganisationMemberInvite[] @relation("OrganisationMemberInviteInviter")
membersInvited OrganisationMember[] @relation("OrganisationMemberInvitedBy")
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
@@ -745,6 +746,11 @@ model OrganisationMember {
organisationGroupMembers OrganisationGroupMember[]
// Who invited this member: the sender of the invitation they accepted. A
// membership admits its holder only while that sender holds ADMIN.
invitedById Int?
invitedBy User? @relation("OrganisationMemberInvitedBy", fields: [invitedById], references: [id], onDelete: SetNull)
@@unique([userId, organisationId])
@@index([organisationId])
}
@@ -762,8 +768,8 @@ model OrganisationMemberInvite {
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationRole OrganisationMemberRole
// Who sent the invitation. A pending invitation makes an account on its
// invitee's first Hanzo IAM sign-in only while its sender holds ADMIN.
// Who sent the invitation. A pending invitation admits its invitee, and may
// be accepted, only while its sender holds ADMIN.
inviterId Int?
inviter User? @relation("OrganisationMemberInviteInviter", fields: [inviterId], references: [id], onDelete: SetNull)
}